/**
 * @file DockTitleBar.java
 * @brief Class implementing a generic base for a dock node title bar.
 *
 * @section License
 *
 *          This file is a part of the DockFX Library. Copyright (C) 2015 Robert B. Colton
 *
 *          This program is free software: you can redistribute it and/or modify it under the terms
 *          of the GNU Lesser General Public License as published by the Free Software Foundation,
 *          either version 3 of the License, or (at your option) any later version.
 *
 *          This program is distributed in the hope that it will be useful, but WITHOUT ANY
 *          WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
 *          PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
 *
 *          You should have received a copy of the GNU Lesser General Public License along with this
 *          program. If not, see <http://www.gnu.org/licenses/>.
 **/

package org.dockfx;

import java.util.HashMap;
import java.util.List;
import java.util.Stack;

import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.stage.Stage;
import javafx.stage.Window;

/**
 * Base class for a dock node title bar that provides the mouse dragging functionality, captioning,
 * docking, and state manipulation.
 * 
 * @since DockFX 0.1
 */
public class DockTitleBar extends HBox implements EventHandler<MouseEvent> {

  /**
   * The DockNode this node is a title bar for.
   */
  private DockNode dockNode;
  /**
   * The label node used for captioning and the graphic.
   */
  private Label label;
  /**
   * State manipulation buttons including close, maximize, detach, and restore.
   */
  private Button closeButton, stateButton;

  /**
   * Creates a default DockTitleBar with captions and dragging behavior.
   * 
   * @param dockNode The docking node that requires a title bar.
   */
  public DockTitleBar(DockNode dockNode) {
    this.dockNode = dockNode;

    label = new Label("Dock Title Bar");
    label.textProperty().bind(dockNode.titleProperty());
    label.graphicProperty().bind(dockNode.graphicProperty());

    stateButton = new Button();
    stateButton.setOnAction(new EventHandler<ActionEvent>() {
      @Override
      public void handle(ActionEvent event) {
        if (dockNode.isFloating()) {
          dockNode.setMaximized(!dockNode.isMaximized());
        } else {
          dockNode.setFloating(true);
        }
      }
    });

    closeButton = new Button();
    closeButton.setOnAction(new EventHandler<ActionEvent>() {
      @Override
      public void handle(ActionEvent event) {
        dockNode.close();
      }
    });
    closeButton.visibleProperty().bind(dockNode.closableProperty());

    // create a pane that will stretch to make the buttons right aligned
    Pane fillPane = new Pane();
    HBox.setHgrow(fillPane, Priority.ALWAYS);

    getChildren().addAll(label, fillPane, stateButton, closeButton);

    this.addEventHandler(MouseEvent.MOUSE_PRESSED, this);
    this.addEventHandler(MouseEvent.DRAG_DETECTED, this);
    this.addEventHandler(MouseEvent.MOUSE_DRAGGED, this);
    this.addEventHandler(MouseEvent.MOUSE_RELEASED, this);

    label.getStyleClass().add("dock-title-label");
    closeButton.getStyleClass().add("dock-close-button");
    stateButton.getStyleClass().add("dock-state-button");
    this.getStyleClass().add("dock-title-bar");
  }

  /**
   * Whether this title bar is currently being dragged.
   *
   * @return Whether this title bar is currently being dragged.
   */
  public final boolean isDragging() {
    return dragging;
  }

  /**
   * The label used for captioning and to provide a graphic.
   *
   * @return The label used for captioning and to provide a graphic.
   */
  public final Label getLabel() {
    return label;
  }

  /**
   * The button used for closing this title bar and its associated dock node.
   *
   * @return The button used for closing this title bar and its associated dock node.
   */
  public final Button getCloseButton() {
    return closeButton;
  }

  /**
   * The button used for detaching, maximizing, or restoring this title bar and its associated dock
   * node.
   *
   * @return The button used for detaching, maximizing, or restoring this title bar and its
   *         associated dock node.
   */
  public final Button getStateButton() {
    return stateButton;
  }

  /**
   * The dock node that is associated with this title bar.
   *
   * @return The dock node that is associated with this title bar.
   */
  public final DockNode getDockNode() {
    return dockNode;
  }

  /**
   * The mouse location of the original click which we can use to determine the offset during drag.
   * Title bar dragging is asynchronous so it will not be negatively impacted by less frequent or
   * lagging mouse events as in the case of most current JavaFX implementations on Linux.
   */
  private Point2D dragStart;
  /**
   * Whether this title bar is currently being dragged.
   */
  private boolean dragging = false;
  /**
   * The current node being dragged over for each window so we can keep track of enter/exit events.
   */
  private HashMap<Window, Node> dragNodes = new HashMap<Window, Node>();

  /**
   * The task that is to be executed when the dock event target is picked. This provides context for
   * what specific events and what order the events should be fired.
   * 
   * @since DockFX 0.1
   */
  private abstract class EventTask {
    /**
     * The number of times this task has been executed.
     */
    protected int executions = 0;

    /**
     * Creates a default DockTitleBar with captions and dragging behavior.
     * 
     * @param node The node that was chosen as the event target.
     * @param dragNode The node that was last event target.
     */
    public abstract void run(Node node, Node dragNode);

    /**
     * The number of times this task has been executed.
     *
     * @return The number of times this task has been executed.
     */
    public int getExecutions() {
      return executions;
    }

    /**
     * Reset the execution count to zero.
     */
    public void reset() {
      executions = 0;
    }
  }

  /**
   * Traverse the scene graph for all open stages and pick an event target for a dock event based on
   * the location. Once the event target is chosen run the event task with the target and the
   * previous target of the last dock event if one is cached. If an event target is not found fire
   * the explicit dock event on the stage root if one is provided.
   * 
   * @param location The location of the dock event in screen coordinates.
   * @param eventTask The event task to be run when the event target is found.
   * @param explicit The explicit event to be fired on the stage root when no event target is found.
   */
  private void pickEventTarget(Point2D location, EventTask eventTask, Event explicit) {
    // RFE for public scene graph traversal API filed but closed:
    // https://bugs.openjdk.java.net/browse/JDK-8133331

    List<DockPane> dockPanes = DockPane.dockPanes;

    // fire the dock over event for the active stages
    for (DockPane dockPane : dockPanes) {
      Window window = dockPane.getScene().getWindow();
      if (!(window instanceof Stage)) continue;
      Stage targetStage = (Stage) window;

      // obviously this title bar does not need to receive its own events
      // though users of this library may want to know when their
      // dock node is being dragged by subclassing it or attaching
      // an event listener in which case a new event can be defined or
      // this continue behavior can be removed
      if (targetStage == this.dockNode.getStage())
        continue;

      eventTask.reset();

      Node dragNode = dragNodes.get(targetStage);

      Parent root = targetStage.getScene().getRoot();
      Stack<Parent> stack = new Stack<Parent>();
      if (root.contains(root.screenToLocal(location.getX(), location.getY()))
          && !root.isMouseTransparent()) {
        stack.push(root);
      }
      // depth first traversal to find the deepest node or parent with no children
      // that intersects the point of interest
      while (!stack.isEmpty()) {
        Parent parent = stack.pop();
        // if this parent contains the mouse click in screen coordinates in its local bounds
        // then traverse its children
        boolean notFired = true;
        for (Node node : parent.getChildrenUnmodifiable()) {
          if (node.contains(node.screenToLocal(location.getX(), location.getY()))
              && !node.isMouseTransparent()) {
            if (node instanceof Parent) {
              stack.push((Parent) node);
            } else {
              eventTask.run(node, dragNode);
            }
            notFired = false;
            break;
          }
        }
        // if none of the children fired the event or there were no children
        // fire it with the parent as the target to receive the event
        if (notFired) {
          eventTask.run(parent, dragNode);
        }
      }

      if (explicit != null && dragNode != null && eventTask.getExecutions() < 1) {
        Event.fireEvent(dragNode, explicit.copyFor(this, dragNode));
        dragNodes.put(targetStage, null);
      }
    }
  }

  @Override
  public void handle(MouseEvent event) {
    if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
      if (dockNode.isFloating() && event.getClickCount() == 2
          && event.getButton() == MouseButton.PRIMARY) {
        dockNode.setMaximized(!dockNode.isMaximized());
      } else {
        // drag detected is used in place of mouse pressed so there is some threshold for the
        // dragging which is determined by the default drag detection threshold
        dragStart = new Point2D(event.getX(), event.getY());
      }
    } else if (event.getEventType() == MouseEvent.DRAG_DETECTED) {
      if (!dockNode.isFloating()) {
        // if we are not using a custom title bar and the user
        // is not forcing the default one for floating and
        // the dock node does have native window decorations
        // then we need to offset the stage position by
        // the height of this title bar
        if (!dockNode.isCustomTitleBar() && dockNode.isDecorated()) {
          dockNode.setFloating(true, new Point2D(0, DockTitleBar.this.getHeight()));
        } else {
          dockNode.setFloating(true);
        }

        // TODO: Find a better solution.
        // Temporary work around for nodes losing the drag event when removed from
        // the scene graph.
        // A possible alternative is to use "ghost" panes in the DockPane layout
        // while making DockNode simply an overlay stage that is always shown.
        // However since flickering when popping out was already eliminated that would
        // be overkill and is not a suitable solution for native decorations.
        // Bug report open: https://bugs.openjdk.java.net/browse/JDK-8133335
        DockPane dockPane = this.getDockNode().getDockPane();
        if (dockPane != null) {
          dockPane.addEventFilter(MouseEvent.MOUSE_DRAGGED, this);
          dockPane.addEventFilter(MouseEvent.MOUSE_RELEASED, this);
        }
      } else if (dockNode.isMaximized()) {
        double ratioX = event.getX() / this.getDockNode().getWidth();
        double ratioY = event.getY() / this.getDockNode().getHeight();

        // Please note that setMaximized is ruined by width and height changes occurring on the
        // stage and there is currently a bug report filed for this though I did not give them an
        // accurate test case which I should and wish I would have. This was causing issues in the
        // original release requiring maximized behavior to be implemented manually by saving the
        // restored bounds. The problem was that the resize functionality in DockNode.java was
        // executing at the same time canceling the maximized change.
        // https://bugs.openjdk.java.net/browse/JDK-8133334

        // restore/minimize the window after we have obtained its dimensions
        dockNode.setMaximized(false);

        // scale the drag start location by our restored dimensions
        dragStart = new Point2D(ratioX * dockNode.getWidth(), ratioY * dockNode.getHeight());
      }
      dragging = true;
      event.consume();
    } else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED) {
      if (dockNode.isFloating() && event.getClickCount() == 2
          && event.getButton() == MouseButton.PRIMARY) {
        event.setDragDetect(false);
        event.consume();
        return;
      }

      if (!dragging)
        return;

      Stage stage = dockNode.getStage();
      Insets insetsDelta = this.getDockNode().getBorderPane().getInsets();

      // dragging this way makes the interface more responsive in the event
      // the system is lagging as is the case with most current JavaFX
      // implementations on Linux
      stage.setX(event.getScreenX() - dragStart.getX() - insetsDelta.getLeft());
      stage.setY(event.getScreenY() - dragStart.getY() - insetsDelta.getTop());

      // TODO: change the pick result by adding a copyForPick()
      DockEvent dockEnterEvent =
          new DockEvent(this, DockEvent.NULL_SOURCE_TARGET, DockEvent.DOCK_ENTER, event.getX(),
              event.getY(), event.getScreenX(), event.getScreenY(), null);
      DockEvent dockOverEvent =
          new DockEvent(this, DockEvent.NULL_SOURCE_TARGET, DockEvent.DOCK_OVER, event.getX(),
              event.getY(), event.getScreenX(), event.getScreenY(), null);
      DockEvent dockExitEvent =
          new DockEvent(this, DockEvent.NULL_SOURCE_TARGET, DockEvent.DOCK_EXIT, event.getX(),
              event.getY(), event.getScreenX(), event.getScreenY(), null);

      EventTask eventTask = new EventTask() {
        @Override
        public void run(Node node, Node dragNode) {
          executions++;

          if (dragNode != node) {
            Event.fireEvent(node, dockEnterEvent.copyFor(DockTitleBar.this, node));

            if (dragNode != null) {
              // fire the dock exit first so listeners
              // can actually keep track of the node we
              // are currently over and know when we
              // aren't over any which DOCK_OVER
              // does not provide
              Event.fireEvent(dragNode, dockExitEvent.copyFor(DockTitleBar.this, dragNode));
            }

            dragNodes.put(node.getScene().getWindow(), node);
          }
          Event.fireEvent(node, dockOverEvent.copyFor(DockTitleBar.this, node));
        }
      };

      this.pickEventTarget(new Point2D(event.getScreenX(), event.getScreenY()), eventTask,
          dockExitEvent);
    } else if (event.getEventType() == MouseEvent.MOUSE_RELEASED) {
      dragging = false;

      DockEvent dockReleasedEvent =
          new DockEvent(this, DockEvent.NULL_SOURCE_TARGET, DockEvent.DOCK_RELEASED, event.getX(),
              event.getY(), event.getScreenX(), event.getScreenY(), null, this.getDockNode());

      EventTask eventTask = new EventTask() {
        @Override
        public void run(Node node, Node dragNode) {
          executions++;
          if (dragNode != node) {
            Event.fireEvent(node, dockReleasedEvent.copyFor(DockTitleBar.this, node));
          }
          Event.fireEvent(node, dockReleasedEvent.copyFor(DockTitleBar.this, node));
        }
      };

      this.pickEventTarget(new Point2D(event.getScreenX(), event.getScreenY()), eventTask, null);

      dragNodes.clear();

      // Remove temporary event handler for bug mentioned above.
      DockPane dockPane = this.getDockNode().getDockPane();
      if (dockPane != null) {
        dockPane.removeEventFilter(MouseEvent.MOUSE_DRAGGED, this);
        dockPane.removeEventFilter(MouseEvent.MOUSE_RELEASED, this);
      }
    }
  }
}