// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.collide.client.util;

import collide.client.util.BrowserUtils;
import collide.client.util.CssUtils;

import com.google.collide.json.client.Jso;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;

import elemental.css.CSSStyleDeclaration;
import elemental.dom.Element;
import elemental.events.Event;
import elemental.events.EventListener;

/*
 * TODO: Here's the list of short-term TODOs:
 * 
 * - Make sure our client measurements are accurate if the element has padding,
 * margins, border, etc. Luckily, the current clients don't have any of these
 * 
 * - So far, clients have used pixels for height, etc., I need to figure out
 * whether other units are kosher with CSS transition.
 * 
 * - There's a bug in Chrome that I need to file, to repro click four times
 * quickly on an the expander arrow, and notice that element is no longer
 * expandable (the element doesn't accept changes with setClassName anymore).
 * 
 * - A way to give the controller multiple mutually exclusive elements and
 * transition smoothly (e.g. I am doing this with the
 * CollaborationNavigationSection, but manually with a show/hide. I think the
 * animation aesthetics can be improved if this controller knows/manages both
 * together as one.)
 * 
 * - For things like fixed height, we can figure it out based on the element.
 * But, we don't want to do it at time of animation because we have to walk the
 * CSSStyleRules. Instead, allow the builder to take in a "template" element
 * that will look like the elements passed to show/hide.
 * 
 * - Add support for non-animated initial state.
 */

/**
 * Controller to aid in animating elements.
 * 
 * Rules:
 * <ul>
 * <li>Initialize the element by calling
 * {@link AnimationController#hideWithoutAnimating(Element)}. Don't set
 * "display: none" in your CSS since the animation controller cannot undo it.
 * <li>Do not modify the {@link Builder} after calling its
 * {@link Builder#build()}.
 * <li>Padding and margins must be specified in px units.
 * </ul>
 */
public class AnimationController {

  public interface AnimationStateListener {
    void onAnimationStateChanged(Element element, State state);
  }

  /**
   * Expands and collapses an element into and out of view.
   */
  public static final AnimationController COLLAPSE_ANIMATION_CONTROLLER =
      new AnimationController.Builder().setCollapse(true).build();

  /**
   * Fades an element into and out of view.
   */
  public static final AnimationController FADE_ANIMATION_CONTROLLER =
      new AnimationController.Builder().setFade(true).build();

  public static final AnimationController COLLAPSE_FADE_ANIMATION_CONTROLLER =
      new AnimationController.Builder().setCollapse(true).setFade(true).build();

  /**
   * Does not animate.
   */
  public static final AnimationController NO_ANIMATION_CONTROLLER =
      new AnimationController.Builder().build();

  /**
   * Builder for the {@link AnimationController}. Do not modify after calling
   * {@link #build()}.
   * 
   */
  public static class Builder {
    private boolean collapse;
    private boolean fade;
    private boolean fixedHeight;

    public AnimationController build() {
      return new AnimationController(this);
    }

    // TODO: shrink height or width?
    /** Defaults to false */
    public Builder setCollapse(boolean collapse) {
      this.collapse = collapse;
      return this;
    }

    /** Defaults to false */
    public Builder setFade(boolean fade) {
      this.fade = fade;
      return this;
    }

    /** Defaults to false */
    public Builder setFixedHeight(boolean fixedHeight) {
      this.fixedHeight = fixedHeight;
      return this;
    }
  }

  /**
   * Handles the end of a CSS transition.
   */
  private abstract class AbstractTransitionEndHandler implements EventListener {
    public void handleEndFor(Element elem) {
      // TODO: Keep an eye on whether or not webkit supports the
      // vendor prefix free version. If they ever do we should remove this.
      elem.addEventListener(Event.WEBKITTRANSITIONEND, this, false);
      // For FF4 when we are ready.
      // elem.addEventListener("transitionend", this, false);
    }

    public void unhandleEndFor(Element elem) {
      elem.removeEventListener(Event.WEBKITTRANSITIONEND, this, false);
      // For FF4 when we are ready.
      // elem.removeEventListener("transitionend", this, false);
    }

    /*
     * GWT complains that AbstractTransitionEndHandler doesn't define
     * handleEvent() if we do not include this abstract method to override the
     * interface method.
     */
    @Override
    public abstract void handleEvent(Event evt);
  }

  /**
   * Handles the end of the show transition.
   */
  private class ShowTransitionEndHandler extends AbstractTransitionEndHandler {

    @Override
    public void handleEvent(Event evt) {
      /*
       * Transition events propagate, so the event target could be a child of
       * the element that we are controlling. For example, the child could be a
       * button (with transitions enabled) within a form that is being animated.
       * 
       * We verify that the target is actually being animated by the
       * AnimationController by checking its current state. It will only have a
       * state if the AnimationController added the state attribute to the
       * target.
       */
      Element target = (Element) evt.getTarget();
      if (isAnyState(target, State.SHOWING)) {
        showWithoutAnimating(target); // Puts element in SHOWN state
      }
    }
  }

  /**
   * Handles the end of the hide transition.
   */
  private class HideTransitionEndHandler extends AbstractTransitionEndHandler {

    @Override
    public void handleEvent(Event evt) {
      /*
       * Transition events propagate, so the event target could be a child of
       * the element that we are controlling. For example, the child could be a
       * button (with transitions enabled) within a form that is being animated.
       * 
       * We verify that the target is actually being animated by the
       * AnimationController by checking its current state. It will only have a
       * state if the AnimationController added the state attribute to the
       * target.
       */
      Element target = (Element) evt.getTarget();
      if (isAnyState(target, State.HIDING)) {
        hideWithoutAnimating(target); // Puts element in HIDDEN state
      }
    }
  }

  /**
   * An attribute added to an element to indicate its state.
   */
  private static final String ATTR_STATE = "__animControllerState";

  /**
   * An attribute added to an element to stash its animation state listener.
   */
  private static final String ATTR_STATE_LISTENER = "__animControllerStateListener";

  /**
   * The states that an element can be in.
   * 
   * The only method we call on the state is {@link State#ordinal()}, which
   * allows the GWT compiler to ordinalize the enums into integer constants.
   */
  public static enum State {
    /**
     * The element is completely hidden.
     */
    HIDDEN,

    /**
     * The element is transitioning to the hidden state.
     */
    HIDING,

    /**
     * The element is completely shown.
     */
    SHOWN,

    /**
     * The element is transitioning to the shown state.
     */
    SHOWING
  }

  final boolean isAnimated; // Visible for testing.
  private final Builder options;
  private final ShowTransitionEndHandler showEndHandler;
  private final HideTransitionEndHandler hideEndHandler;

  private AnimationController(Builder builder) {
    this.options = builder;
    this.showEndHandler = new ShowTransitionEndHandler();
    this.hideEndHandler = new HideTransitionEndHandler();

    /*
     * TODO: Remove this entirely when we move
     * to FF4.0. Animations do not work on older versions of FF.
     */
    boolean isFirefox = BrowserUtils.isFirefox();

    /*
     * If none of the animated properties are being animated, then the CSS
     * transition end listener may not execute at all. In that case, we
     * show/hide the element immediately.
     */
    this.isAnimated = !isFirefox && (options.collapse || options.fade);
  }

  /**
   * Animate the element out of view. Do not enable transitions in the CSS for this element, or the
   * animations may not work correctly. AnimationController will enable animations automatically.
   * 
   * @see #hideWithoutAnimating(Element)
   */
  public void hide(final Element element) {
    // Early exit if the element is hidden or hiding.
    if (isAnyState(element, State.HIDDEN, State.HIDING)) {
      return;
    }

    if (!isAnimated) {
      hideWithoutAnimating(element);
      return;
    }

    // Cancel pending transition event listeners.
    showEndHandler.unhandleEndFor(element);

    final CSSStyleDeclaration style = element.getStyle();

    if (options.collapse) {
      // Set height because the CSS transition requires one
      int height = getCurrentHeight(element);
      style.setHeight(height + CSSStyleDeclaration.Unit.PX);
    }

    // Give the browser a chance to accept the height set above
    setState(element, State.HIDING);
    schedule(element, new ScheduledCommand() {
      @Override
      public void execute() {
        // The user changed the state before this command executed.
        if (!clearLastCommand(element, this) || !isAnyState(element, State.HIDING)) {
          return;
        }

        if (options.collapse) {
          /*
           * Hide overflow if changing height, or the overflow will be visible
           * even as the element collapses.
           */
          AnimationUtils.backupOverflow(style);
        }
        AnimationUtils.enableTransitions(style);

        if (options.collapse) {
          // Animate all properties that could affect height if collapsing.
          style.setHeight("0");
          style.setMarginTop("0");
          style.setMarginBottom("0");
          style.setPaddingTop("0");
          style.setPaddingBottom("0");
          CssUtils.setBoxShadow(element, "0 0");
        }

        if (options.fade) {
          style.setOpacity(0);
        }
      }
    });

    // For webkit based browsers.
    hideEndHandler.handleEndFor(element);
  }

  /**
   * Animates the element into view. Do not enable transitions in the CSS for this element, or the
   * animations may not work correctly. AnimationController will enable animations automatically.
   */
  public void show(final Element element) {
    // Early exit if the element is shown or showing.
    if (isAnyState(element, State.SHOWN, State.SHOWING)) {
      return;
    }
    
    if (!isAnimated) {
      showWithoutAnimating(element);
      return;
    }

    // Cancel pending transition event listeners.
    hideEndHandler.unhandleEndFor(element);

    /*
     * Make this "visible" again so we can measure its eventual height (required
     * for CSS transitions). We will set its initial state in this event loop,
     * so the element will not be fully visible.
     */
    final CSSStyleDeclaration style = element.getStyle();
    element.getStyle().removeProperty("display");
    final int measuredHeight = getCurrentHeight(element);

    /*
     * Set the initial state, but not if the element is in the process of
     * hiding.
     */
    if (!isAnyState(element, State.HIDING)) {
      if (options.collapse) {
        // Start the animation at a height of zero.
        style.setHeight("0");

        // We want to animate from total height of 0
        style.setMarginTop("0");
        style.setMarginBottom("0");
        style.setPaddingTop("0");
        style.setPaddingBottom("0");
        CssUtils.setBoxShadow(element, "0 0");

        /*
         * Hide overflow if expanding the element, or the entire element will be
         * instantly visible. Do not do this by default, because it could hide
         * absolutely positioned elements outside of the root element, such as
         * the arrow on a tooltip.
         */
        AnimationUtils.backupOverflow(style);
      }

      if (options.fade) {
        style.setOpacity(0);
      }
    }

    // Give the browser a chance to accept the properties set above
    setState(element, State.SHOWING);
    schedule(element, new ScheduledCommand() {
      @Override
      public void execute() {
        // The user changed the state before this command executed.
        if (!clearLastCommand(element, this) || !isAnyState(element, State.SHOWING)) {
          return;
        }

        // Enable animations before setting the end state.
        AnimationUtils.enableTransitions(style);

        // Set the end state.
        if (options.collapse) {
          if (options.fixedHeight) {
            // The element's styles have a fixed height set, so we just want to
            // clear our override
            style.setHeight("");
          } else {
            // Give it an explicit height to animate to, because the element's
            // height is auto otherwise
            style.setHeight(measuredHeight + CSSStyleDeclaration.Unit.PX);
          }

          style.removeProperty("margin-top");
          style.removeProperty("margin-bottom");
          style.removeProperty("padding-top");
          style.removeProperty("padding-bottom");
          CssUtils.removeBoxShadow(element);
        }

        if (options.fade) {
          style.setOpacity(1);
        }
      }
    });

    // For webkit based browsers.
    showEndHandler.handleEndFor(element);
  }

  /**
   * Checks if the specified element is logically hidden, which is true if it is
   * hidden or in the process of hiding.
   */
  public boolean isHidden(Element element) {
    return isAnyState(element, State.HIDDEN, State.HIDING);
  }

  /**
   * Returns the height as would be set on the CSS "height" property.
   */
  private int getCurrentHeight(final Element element) {
    // TODO: test to see if horizontal scroll plays nicely
    CSSStyleDeclaration style = CssUtils.getComputedStyle(element);
    return element.getClientHeight() - CssUtils.parsePixels(style.getPaddingTop())
        - CssUtils.parsePixels(style.getPaddingBottom());
  }
  
  public void setVisibilityWithoutAnimating(Element element, boolean visibile) {
    if (visibile) {
      showWithoutAnimating(element);
    } else {
      hideWithoutAnimating(element);
    }
  }

  /**
   * Hide the element without animating it out of view. Use this method to set
   * the initial state of the element.
   */
  public void hideWithoutAnimating(Element element) {
    if (isAnyState(element, State.HIDDEN)) {
      return;
    }
    cancel(element);
    element.getStyle().setDisplay(CSSStyleDeclaration.Display.NONE);
    setState(element, State.HIDDEN);
  }

  /**
   * Show the element without animating it into view.
   */
  public void showWithoutAnimating(Element element) {
    if (isAnyState(element, State.SHOWN)) {
      return;
    }
    cancel(element);
    element.getStyle().removeProperty("display");
    setState(element, State.SHOWN);
  }

  /**
   * Sets the listener for animation state change events.
   *
   * <p>
   * If an element is not visible in the UI when an animation is applied, the animation will never
   * complete and the element will stay in the state HIDING until some other animation is applied.
   */
  public void setAnimationStateListener(Element element, AnimationStateListener listener) {
    ((Jso) element).addField(ATTR_STATE_LISTENER, listener);
  }

  public AnimationStateListener getAnimationStateListener(Element element) {
    return (AnimationStateListener) ((Jso) element).getJavaObjectField(ATTR_STATE_LISTENER);
  }

  /**
   * Cancel the currently executing animation without completing it.
   */
  private void cancel(Element element) {
    // Cancel all handlers.
    setLastCommandImpl(element, null);
    hideEndHandler.unhandleEndFor(element);
    showEndHandler.unhandleEndFor(element);

    // Disable animations.
    CSSStyleDeclaration style = element.getStyle();
    AnimationUtils.removeTransitions(style);
    if (options.collapse) {
      AnimationUtils.restoreOverflow(style);
    }

    // Remove the height and properties we set.
    if (options.collapse) {
      style.removeProperty("height");
      style.removeProperty("margin-top");
      style.removeProperty("margin-bottom");
      style.removeProperty("padding-top");
      style.removeProperty("padding-bottom");
    }
    if (options.fade) {
      style.removeProperty("opacity");
    }
    CssUtils.removeBoxShadow(element);
  }

  private void setState(Element element, State state) {
    element.setAttribute(ATTR_STATE, Integer.toString(state.ordinal()));

    AnimationStateListener listener = getAnimationStateListener(element);
    if (listener != null) {
      listener.onAnimationStateChanged(element, state);
    }
  }

  /**
   * Check if the element is in any of the specified states.
   * 
   * @param states the states to check, null is not allowed
   * @return true if in any one of the states
   */
  // Visible for testing.
  boolean isAnyState(Element element, State... states) {
    // Get the state ordinal from the attribute.
    String ordinalStr = element.getAttribute(ATTR_STATE);

    // NOTE: The following NULL check makes a dramatic performance impact!
    if (ordinalStr == null) {
      return false;
    }

    int ordinal = -1;
    try {
      ordinal = Integer.parseInt(ordinalStr);
    } catch (NumberFormatException e) {
      // The element's state has not been initialized yet.
      return false;
    }

    for (State state : states) {
      if (ordinal == state.ordinal()) {
        return true;
      }
    }
    return false;
  }

  /**
   * Schedule a command to execute on the specified element. Use
   * {@link #clearLastCommand(Element, ScheduledCommand)} to verify that the
   * command is still the most recent command scheduled for the element.
   */
  private void schedule(Element element, ScheduledCommand command) {
    setLastCommandImpl(element, command);
    Scheduler.get().scheduleDeferred(command);
  }

  /**
   * Clear the last command from the specified element if the last command
   * scheduled equals the specified command.
   * 
   * @return true if the last command equals the specified command, false if no
   */
  private native boolean clearLastCommand(Element element, ScheduledCommand command) /*-{
    if (element.__gwtLastCommand == command) {
      element.__gwtLastCommand = null; // Clear the last command if it is about to execute.
      return true;
    }
    return false;
  }-*/;

  private native void setLastCommandImpl(Element element, ScheduledCommand command) /*-{
    element.__gwtLastCommand = command;
  }-*/;
}
