// 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 org.eclipse.che.ide.ui;

import com.google.gwt.core.client.GWT;
import com.google.gwt.resources.client.ClientBundle;
import com.google.gwt.resources.client.CssResource;
import elemental.dom.Element;
import elemental.dom.Node;
import elemental.events.Event;
import elemental.events.EventListener;
import elemental.events.EventRemover;
import elemental.events.EventTarget;
import elemental.events.MouseEvent;
import elemental.util.Timer;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.che.ide.ui.menu.AutoHideComponent;
import org.eclipse.che.ide.ui.menu.AutoHideView;
import org.eclipse.che.ide.ui.menu.PositionController;
import org.eclipse.che.ide.util.AnimationController;
import org.eclipse.che.ide.util.HoverController;
import org.eclipse.che.ide.util.dom.Elements;
import org.eclipse.che.ide.util.loging.Log;

/** Represents a single tooltip instance attached to any element, activated by hovering. */
/*
 * TODO: oh, my god this thing has become a monster. Might be nice to
 * get a list of requirements and start from the top... especially if we need
 * some coach marks as well for the landing page.
 */
public class Tooltip
    extends AutoHideComponent<AutoHideView<Void>, AutoHideComponent.AutoHideModel> {

  private static final Resources RESOURCES = GWT.create(Resources.class);

  private static final int SHOW_DELAY = 600;
  private static final int HIDE_DELAY = 600;

  /** The singleton view instance that all tooltips use. */
  private static AutoHideView<Void> tooltipViewInstance;

  /** The currently active tooltip that is bound to the view. */
  private static Tooltip activeTooltip;

  /** Holds a reference to the css. */
  private final Css css;

  private final List<Element> targetElements;
  private final Timer showTimer;
  private final TooltipRenderer renderer;
  private final PositionController positionController;
  private final List<EventRemover> eventRemovers;
  private final PositionController.Positioner positioner;
  private Element contentElement;
  private String title;
  private String maxWidth;
  private boolean isEnabled = true;
  private boolean isShowDelayDisabled;

  static {
    RESOURCES.tooltipCss().ensureInjected();
  }

  private Tooltip(
      AutoHideView<Void> view,
      Resources res,
      List<Element> targetElements,
      PositionController.Positioner positioner,
      TooltipRenderer renderer,
      boolean shouldShowOnHover) {
    super(view, new AutoHideModel());
    this.positioner = positioner;
    this.renderer = renderer;
    this.css = res.tooltipCss();
    this.targetElements = targetElements;

    this.eventRemovers =
        shouldShowOnHover ? attachToTargetElement() : new ArrayList<EventRemover>();

    getView().setAnimationController(AnimationController.FADE_ANIMATION_CONTROLLER);

    positionController = new PositionController(positioner, getView().getElement());

    showTimer =
        new Timer() {
          @Override
          public void run() {
            show();
          }
        };
    setDelay(HIDE_DELAY);
    setCaptureOutsideClickOnClose(false);

    getHoverController()
        .setHoverListener(
            new HoverController.HoverListener() {
              @Override
              public void onHover() {
                if (isEnabled && !isShowing()) {
                  deferredShow();
                }
              }
            });
  }

  /** Static factory method for creating a simple tooltip. */
  public static Tooltip create(
      Element targetElement,
      PositionController.VerticalAlign vAlign,
      PositionController.HorizontalAlign hAlign,
      String... tooltipText) {
    return new Builder(
            targetElement,
            new TooltipPositionerBuilder()
                .setVerticalAlign(vAlign)
                .setHorizontalAlign(hAlign)
                .buildAnchorPositioner(targetElement))
        .setTooltipRenderer(new SimpleStringRenderer(tooltipText))
        .build();
  }

  /** Static factory method for creating a simple tooltip with given element as content. */
  public static Tooltip create(
      Element targetElement,
      PositionController.VerticalAlign vAlign,
      PositionController.HorizontalAlign hAlign,
      final Element tooltipContent) {
    return new Builder(
            targetElement,
            new TooltipPositionerBuilder()
                .setVerticalAlign(vAlign)
                .setHorizontalAlign(hAlign)
                .buildAnchorPositioner(targetElement))
        .setTooltipRenderer(
            new TooltipRenderer() {
              @Override
              public Element renderDom() {
                return tooltipContent;
              }
            })
        .build();
  }

  /** The Tooltip is a flyweight that uses a singleton View base element. */
  private static AutoHideView<Void> getViewInstance(Css css) {
    if (tooltipViewInstance == null) {
      tooltipViewInstance = new AutoHideView<Void>(Elements.createDivElement());
      Elements.addClassName(css.tooltipPosition(), tooltipViewInstance.getElement());
    }
    return tooltipViewInstance;
  }

  @Override
  public void show() {
    // Nothing to do if it is showing.
    if (isShowing()) {
      return;
    }

    /*
     * Hide the old Tooltip. This will not actually hide the View because we set
     * activeTooltip to null.
     */
    if (activeTooltip != null) {
      activeTooltip.hide();
    }

    ensureContent();

    // Bind to the singleton view.
    getView().getElement().setInnerHTML("");
    getView().getElement().appendChild(contentElement);
    positionController.updateElementPosition();
    activeTooltip = this;

    super.show();
  }

  @Override
  public void forceHide() {
    super.forceHide();
    activeTooltip = null;
  }

  @Override
  protected void hideView() {
    // If another tooltip is being shown, do not hide the shared view.
    if (activeTooltip == this) {
      super.hideView();
    }
  }

  public void setTitle(String title) {
    this.title = title;
  }

  public void setMaxWidth(String maxWidth) {
    this.maxWidth = maxWidth;

    // Update the content element if it is already created.
    if (contentElement != null) {
      if (maxWidth == null) {
        contentElement.getStyle().removeProperty("max-width");
      } else {
        contentElement.getStyle().setProperty("max-width", maxWidth);
      }
    }
  }

  /**
   * Enables or disables the show delay. If disabled, the tooltip will appear instantly on hover.
   * Defaults to enabled.
   *
   * @param isDisabled true to disable the show delay
   */
  public void setShowDelayDisabled(boolean isDisabled) {
    this.isShowDelayDisabled = isDisabled;
  }

  /** Enable or disable this tooltip */
  public void setEnabled(boolean isEnabled) {
    this.isEnabled = isEnabled;
  }

  private void setPositionStyle() {
    PositionController.HorizontalAlign hAlign = positioner.getHorizontalAlignment();
    switch (positioner.getVerticalAlignment()) {
      case TOP:
        Elements.addClassName(css.tooltipAbove(), contentElement);
        break;
      case BOTTOM:
        if (hAlign == PositionController.HorizontalAlign.RIGHT) {
          Elements.addClassName(css.tooltipBelowRightAligned(), contentElement);
        } else {
          Elements.addClassName(css.tooltipBelow(), contentElement);
        }
        break;
      case MIDDLE:
        if (hAlign == PositionController.HorizontalAlign.LEFT) {
          Elements.addClassName(css.tooltipLeft(), contentElement);
        } else if (hAlign == PositionController.HorizontalAlign.RIGHT) {
          Elements.addClassName(css.tooltipRight(), contentElement);
        }
        break;
      default:
    }
  }

  /**
   * Adds event handlers to the target element for the tooltip to show it on hover, and update
   * position on mouse move.
   */
  private List<EventRemover> attachToTargetElement() {
    List<EventRemover> removers = new ArrayList<>();
    for (int i = 0; i < targetElements.size(); i++) {
      final Element targetElement = targetElements.get(i);
      addPartner(targetElement);

      removers.add(
          targetElement.addEventListener(
              Event.MOUSEOUT,
              new EventListener() {
                @Override
                public void handleEvent(Event evt) {
                  MouseEvent mouseEvt = (MouseEvent) evt;
                  EventTarget relatedTarget = mouseEvt.getRelatedTarget();
                  // Ignore the event unless we mouse completely out of the target element.
                  if (relatedTarget == null || !targetElement.contains((Node) relatedTarget)) {
                    cancelPendingShow();
                  }
                }
              },
              false));

      removers.add(
          targetElement.addEventListener(
              Event.MOUSEDOWN,
              new EventListener() {
                @Override
                public void handleEvent(Event evt) {
                  cancelPendingShow();
                  hide();
                }
              },
              false));
    }

    return removers;
  }

  /** Removes event handlers from the target element for the tooltip. */
  private void detachFromTargetElement() {
    for (int i = 0; i < targetElements.size(); i++) {
      removePartner(targetElements.get(i));
    }
    for (int i = 0, n = eventRemovers.size(); i < n; ++i) {
      eventRemovers.get(i).remove();
    }
    eventRemovers.clear();
  }

  /**
   * Creates the dom for this tooltip's content.
   *
   * <p><code>
   * <div class="tooltipPosition">
   * <div class="tooltip tooltipAbove/Below/Left/Right">
   * tooltipText
   * <div class="tooltipTriangle"></div>
   * </div>
   * </div>
   * </code>
   */
  private void ensureContent() {
    if (contentElement == null) {
      contentElement = renderer.renderDom();

      if (contentElement == null) {
        // Guard against malformed renderers.
        Log.warn(getClass(), "Renderer for tooltip returned a null content element");
        contentElement = Elements.createDivElement();
        contentElement.setTextContent("An empty Tooltip!");
      }

      if (title != null) {
        // Insert a title if one is set.
        Element titleElem = Elements.createElement("b");
        titleElem.setTextContent(title);
        Element breakElem = Elements.createBRElement();
        contentElement.insertBefore(breakElem, contentElement.getFirstChild());
        contentElement.insertBefore(titleElem, contentElement.getFirstChild());
      }

      // Set the maximum width.
      setMaxWidth(maxWidth);

      Elements.addClassName(css.tooltip(), contentElement);
      Element triangle = Elements.createDivElement(css.triangle());
      contentElement.appendChild(triangle);

      setPositionStyle();
    }
  }

  public void destroy() {
    showTimer.cancel();
    forceHide();
    detachFromTargetElement();
  }

  private void deferredShow() {
    if (isShowDelayDisabled || activeTooltip != null) {
      /*
       * If there is already a tooltip showing and the user mouses over an item
       * that has it's own tooltip, move the tooltip immediately. We don't want
       * to leave a lingering tooltip on the old item.
       */
      showTimer.cancel();
      showTimer.run();
    } else {
      showTimer.schedule(SHOW_DELAY);
    }
  }

  private void cancelPendingShow() {
    showTimer.cancel();
  }

  /** Interface for specifying an arbitrary renderer for tooltips. */
  public interface TooltipRenderer {
    Element renderDom();
  }

  public interface Css extends CssResource {
    String tooltipPosition();

    String tooltip();

    String triangle();

    String tooltipAbove();

    String tooltipRight();

    String tooltipBelow();

    String tooltipLeft();

    String tooltipBelowRightAligned();
  }

  public interface Resources extends ClientBundle {
    @Source({
      "org/eclipse/che/ide/ui/constants.css",
      "Tooltip.css",
      "org/eclipse/che/ide/api/ui/style.css"
    })
    Css tooltipCss();
  }

  /** A builder used to construct a new Tooltip. */
  public static class Builder {

    private final Resources res;
    private final List<Element> targetElements;
    private final PositionController.Positioner positioner;
    private boolean shouldShowOnHover = true;
    private TooltipRenderer renderer;

    /** @see TooltipPositionerBuilder */
    public Builder(Element targetElement, PositionController.Positioner positioner) {
      this.res = RESOURCES;
      this.positioner = positioner;
      this.targetElements = new ArrayList<>();
      this.targetElements.add(targetElement);
    }

    /**
     * Adds additional target elements. If the user hovers over any of the target elements, the
     * tooltip will appear.
     */
    public Builder addTargetElements(Element... additionalTargets) {
      for (int i = 0; i < additionalTargets.length; i++) {
        targetElements.add(additionalTargets[i]);
      }
      return this;
    }

    /**
     * Sets the tooltip text. Each item in the array appears on a new line. This method overwrites
     * the tooltip renderer.
     */
    public Builder setTooltipText(String... tooltipText) {
      return setTooltipRenderer(new SimpleStringRenderer(tooltipText));
    }

    public Builder setTooltipRenderer(TooltipRenderer renderer) {
      this.renderer = renderer;
      return this;
    }

    /** If false, will prevent the tooltip from automatically showing on hover. */
    public Builder setShouldListenToHover(boolean shouldShowOnHover) {
      this.shouldShowOnHover = shouldShowOnHover;
      return this;
    }

    public Tooltip build() {
      return new Tooltip(
          getViewInstance(res.tooltipCss()),
          res,
          targetElements,
          positioner,
          renderer,
          shouldShowOnHover);
    }
  }

  /**
   * A {@link org.eclipse.che.ide.ui.menu.PositionController.PositionerBuilder} which uses some more
   * convenient defaults for tooltips. This builder defaults to {@link
   * org.eclipse.che.ide.ui.menu.PositionController.VerticalAlign#BOTTOM} {@link
   * org.eclipse.che.ide.ui.menu.PositionController.HorizontalAlign#MIDDLE} and {@link
   * org.eclipse.che.ide.ui.menu.PositionController.Position#NO_OVERLAP}.
   */
  public static class TooltipPositionerBuilder extends PositionController.PositionerBuilder {
    public TooltipPositionerBuilder() {
      setVerticalAlign(PositionController.VerticalAlign.BOTTOM);
      setHorizontalAlign(PositionController.HorizontalAlign.MIDDLE);
      setPosition(PositionController.Position.NO_OVERLAP);
    }
  }

  /** Default renderer that simply renders the tooltip text with no other DOM. */
  private static class SimpleStringRenderer implements TooltipRenderer {
    private final String[] tooltipText;

    SimpleStringRenderer(String... tooltipText) {
      this.tooltipText = tooltipText;
    }

    @Override
    public Element renderDom() {
      Element content = Elements.createDivElement();
      int i = 0;
      for (String p : tooltipText) {
        content.appendChild(Elements.createTextNode(p));
        if (i < tooltipText.length - 1) {
          content.appendChild(Elements.createBRElement());
        }
        i++;
      }
      return content;
    }
  }
}
