package be.iminds.ilabt.jfed.experimenter_gui.canvas;

import be.iminds.ilabt.jfed.experimenter_gui.canvas.gts.GtsCanvasLink;
import be.iminds.ilabt.jfed.experimenter_gui.canvas.rspec.RspecCanvasLink;
import be.iminds.ilabt.jfed.experimenter_gui.util.force.*;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * User: twalcari
 * Date: 11/14/13
 * Time: 12:21 PM
 */
public abstract class ExperimentCanvas extends ScrollPane {
    private static final Logger LOG = LoggerFactory.getLogger(ExperimentCanvas.class);
    public static final double MAX_CURSOR_NODE_DISTANCE = 40;
    public static final double SQUARED_MAX_CURSOR_TARGET_DISTANCE =
            MAX_CURSOR_NODE_DISTANCE * MAX_CURSOR_NODE_DISTANCE;
    private static final double MIN_ZOOM = 0.25, MAX_ZOOM = 5, ZOOM_FACTOR = 1.5;

    private final Pane pCanvas;
    private final ObjectProperty<CanvasStyle> canvasStyle = new SimpleObjectProperty<>(CanvasStyle.EDITABLE);

    /**
     * These variables are used to perform dragging of the canvas
     */
    private Point2D dragOrigin;
    private Point2D scrollPaneStart;
    private final SelectionProvider selectionProvider = new SelectionProvider();
    private double zoom = 1;

    private final List<RspecCanvasLink> rspecCanvasLinks = new ArrayList<>();

    //instance initializer
    {
        this.getStylesheets().add(ExperimentCanvas.class.getResource("canvas.css").toExternalForm());

        //create content pane
        this.pCanvas = new Pane();
        this.pCanvas.getStyleClass().add("canvas");
        setContent(pCanvas);


        //Make sure that the canvas is at least as big as the viewport of the scrollpane, otherwise the background of the
        // canvas-pane will not fill the scrollpane.
        viewportBoundsProperty().addListener((observableValue, oldValue, newValue) -> {
            if (newValue.getWidth() > pCanvas.getMinWidth()) {
                pCanvas.setMinWidth(newValue.getWidth());
            }

            if (newValue.getHeight() > pCanvas.getMinHeight()) {
                pCanvas.setMinHeight(newValue.getHeight());
            }
        });

        pCanvas.setOnMousePressed(this::onCanvasMousePressed);
        pCanvas.setOnMouseDragged(this::onCanvasMouseDragged);
        pCanvas.setOnMouseReleased(this::onCanvasMouseReleased);
        pCanvas.setOnMouseMoved(this::onCanvasMouseMoved);

        //set canvas background, and make sure that it changes when canvasStyle-field changes
        pCanvas.getStyleClass().add(canvasStyle.get().getStyleName());
        canvasStyle.addListener((observable, oldValue, newValue) -> {
            LOG.trace("Changing experiment canvas style to {}", newValue);

            pCanvas.getStyleClass().remove(oldValue.getStyleName());
            pCanvas.getStyleClass().add(newValue.getStyleName());
        });

        selectionProvider.selectedObjectProperty().addListener((observable, oldValue, newValue) -> {
            if (oldValue instanceof CanvasNode) {
                ((CanvasNode) oldValue).setSelected(false);
            }

            if (newValue instanceof CanvasNode) {
                ((CanvasNode) newValue).setSelected(true);
            }
        });
    }


    public ExperimentCanvas() {
    }


    public void registerRspecCanvasLink(RspecCanvasLink link) {
        rspecCanvasLinks.add(link);
    }


    public void unregisterRspecCanvasLink(RspecCanvasLink link) {
        rspecCanvasLinks.remove(link);
    }


    /**
     * This method is called when a mouseclick is performed on a canvas node
     */
    public void canvasItemOnMouseClicked(MouseEvent mouseEvent) {
        if (mouseEvent.getButton() == MouseButton.PRIMARY) {
            if (mouseEvent.getSource() instanceof CanvasNode ||
                    mouseEvent.getSource() instanceof CanvasLink) {
                getSelectionProvider().setSelectedObject(mouseEvent.getSource());
            } else {
                LOG.warn("unexpected item selected: " + mouseEvent.getSource());
            }

            mouseEvent.consume();
        }
    }

    /**
     * This method is called when a dragDetected-event is caught by a canvas node
     */
    protected void canvasNodeOnDragDetected(MouseEvent mouseEvent) {
        CanvasNode node = (CanvasNode) mouseEvent.getSource();
        getSelectionProvider().setSelectedObject(node);

        mouseEvent.consume();
    }

    /**
     * This method is called when a mouseDragged-event is caught by a canvas node
     */
    protected void canvasNodeOnMouseDragged(MouseEvent mouseEvent) {
    }

    /**
     * This method handles the end of a panning-operation
     */
    protected void onCanvasMouseReleased(MouseEvent mouseEvent) {
        dragOrigin = null;
        scrollPaneStart = null;
    }

    protected void onCanvasMouseMoved(MouseEvent mouseEvent) {
    }

    private void onCanvasMousePressed(MouseEvent mouseEvent) {
        dragOrigin = new Point2D(mouseEvent.getSceneX(), mouseEvent.getSceneY());
        scrollPaneStart = new Point2D(getHvalue(), getVvalue());
    }

    /**
     * Performs panning of the canvas
     */
    protected void onCanvasMouseDragged(MouseEvent mouseEvent) {
        if (dragOrigin == null)
            return;
        //perform panning instead of connecting nodes
        double dx = dragOrigin.getX() - mouseEvent.getSceneX();
        double dy = dragOrigin.getY() - mouseEvent.getSceneY();

        setHvalue(scrollPaneStart.getX() + dx);
        setVvalue(scrollPaneStart.getY() + dy);
    }

    protected Pane getCanvas() {
        return pCanvas;
    }


    public static Bounds calculateBoundsInCanvas(Node node) {
        Bounds localBounds = node.getBoundsInLocal();
        return new BoundingBox(
                node.getLayoutX() + localBounds.getMinX(),
                node.getLayoutY() + localBounds.getMinY(),
                localBounds.getMinZ(),
                localBounds.getWidth(),
                localBounds.getHeight(),
                localBounds.getDepth());
    }


    public SelectionProvider getSelectionProvider() {
        return selectionProvider;
    }


    public void autoLayout() {
        Runnable layouter = () -> {
            //initialize simulator
            ForceSimulator forceSimulator = new ForceSimulator();
            forceSimulator.addForce(new NBodyForce());
            forceSimulator.addForce(new SpringForce());
            forceSimulator.addForce(new DragForce());

            Map<CanvasNode, ForceItem> nodeToForceItemMap = new HashMap<>();
            for (CanvasNode node : getCanvasNodes()) {
                ForceItem nodeForceItem = new ForceItem();
                nodeForceItem.location[0] = (float) (Math.random() * getWidth());
                nodeForceItem.location[1] = (float) (Math.random() * getHeight());
                nodeForceItem.mass = 1.0f;

                forceSimulator.addItem(nodeForceItem);
                nodeToForceItemMap.put(node, nodeForceItem);
            }

            for (RspecCanvasLink link : rspecCanvasLinks) {
                List<RspecCanvasLink.InterfaceLink> ifaces = new ArrayList<>(link.getInterfaceLinks());
                for (int i = 0; i < ifaces.size(); i++) {
                    for (int j = i + 1; j < ifaces.size(); j++) {
                        forceSimulator.addSpring(nodeToForceItemMap.get(ifaces.get(i).getNodeA()),
                                nodeToForceItemMap.get(ifaces.get(j).getNodeA()));
                    }
                }
            }

            //now run the forcesimulator

            long timestep = 1000L;
            for (int i = 0; i < AUTOLAYOUT_ITERATIONS; i++) {
                // use an annealing schedule to set time step
                timestep *= (1.0 - i / (double) AUTOLAYOUT_ITERATIONS);
                long step = timestep + 50;
                // run simulator
                forceSimulator.runSimulator(step);
            }

            //give feedback to the gui
            Platform.runLater(() -> {

                double minX = nodeToForceItemMap.values().stream().mapToDouble(fi -> fi.location[0]).min().orElse(0);
                double maxX = nodeToForceItemMap.values().stream().mapToDouble(fi -> fi.location[0]).max().orElse(0);
                double minY = nodeToForceItemMap.values().stream().mapToDouble(fi -> fi.location[1]).min().orElse(0);
                double maxY = nodeToForceItemMap.values().stream().mapToDouble(fi -> fi.location[1]).max().orElse(0);

                double maxWidth = nodeToForceItemMap.keySet().stream().mapToDouble(Region::getWidth).max().orElse(150);
                double maxHeight = nodeToForceItemMap.keySet().stream().mapToDouble(Region::getHeight).max().orElse(50);

                maxWidth +=50;
                maxHeight +=25;

                for (Map.Entry<CanvasNode, ForceItem> entry : nodeToForceItemMap.entrySet()) {
                    ForceItem forceItem = entry.getValue();
                    CanvasNode node = entry.getKey();
                    double x = (((forceItem.location[0] - minX) / (1+maxX - minX)) * (getViewportBounds().getWidth() - maxWidth)) + maxWidth;
                    node.setLayoutX(x - (node.getWidth()));
                    double y = (((forceItem.location[1] - minY) / (1+maxY - minY)) * (getViewportBounds().getHeight() - maxHeight)) + maxHeight;
                    node.setLayoutY(y - (node.getHeight()));

//                    LOG.debug("Setting node {} to {},{}", node.getText(), x, y);
                }
            });
        };


        Thread layouterThread = new Thread(layouter);
        layouterThread.setName("Layouter-" + layouterThreadNumber.incrementAndGet());
        layouterThread.start();

    }


    private static final int AUTOLAYOUT_ITERATIONS = 100;
    private static final int AUTOLAYOUT_MAXSTEP = 50;
    private static AtomicInteger layouterThreadNumber = new AtomicInteger();

    private List<CanvasNode> getCanvasNodes() {
        return pCanvas.getChildren().stream().filter(child -> child instanceof CanvasNode).map(CanvasNode.class::cast).collect(Collectors.toList());
    }


    public double getZoom() {
        return zoom;
    }

    public void setZoom(double zoom) {
        if (zoom < MIN_ZOOM || zoom > MAX_ZOOM)
            throw new IllegalArgumentException("Zoom must be between " + MIN_ZOOM + " and " + MAX_ZOOM);


        double transform = zoom / this.zoom;
        //update the location of all nodes
        for (CanvasNode node : getCanvasNodes()) {
            node.setLayoutX(node.getLayoutX() * transform);
            node.setLayoutY(node.getLayoutY() * transform);
        }

        this.zoom = zoom;
    }

    public void zoomIn() {
        double newZoom = zoom * ZOOM_FACTOR;
        if (newZoom > MAX_ZOOM)
            newZoom = MAX_ZOOM;

        setZoom(newZoom);
    }

    public void zoomOut() {
        double newZoom = zoom / ZOOM_FACTOR;
        if (newZoom < MIN_ZOOM)
            newZoom = MIN_ZOOM;

        setZoom(newZoom);
    }


    public void addNode(CanvasNode node) {
        assert node.getParent() == null;

        pCanvas.getChildren().add(node);

        node.setOnMouseClicked(this::canvasItemOnMouseClicked);
        node.setOnDragDetected(this::canvasNodeOnDragDetected);
        node.setOnMouseDragged(this::canvasNodeOnMouseDragged);
    }

    public void removeNode(CanvasNode node) {
        assert pCanvas.getChildren().contains(node);

        pCanvas.getChildren().remove(node);

        node.setOnMouseClicked(null);
        node.setOnDragDetected(null);
        node.setOnMouseDragged(null);
    }

    public void addLinkCenter(RspecCanvasLink.LinkCenter linkCenter) {
        assert linkCenter.getParent() == null;
        pCanvas.getChildren().add(linkCenter);

        linkCenter.setOnMouseClicked(this::canvasItemOnMouseClicked);
    }

    public void addLinkCenter(GtsCanvasLink linkCenter) {
        assert linkCenter.getParent() == null;
        pCanvas.getChildren().add(linkCenter);

        linkCenter.setOnMouseClicked(this::canvasItemOnMouseClicked);
    }

    public void addLink(CanvasLink link) {
        assert link.getParent() == null;

        pCanvas.getChildren().add(0, link);

        link.setOnMouseClicked(this::canvasItemOnMouseClicked);
    }

    public void removeLink(CanvasLink canvasLink) {
        assert pCanvas.getChildren().contains(canvasLink);
        pCanvas.getChildren().remove(canvasLink);
    }

    public void removeLinkCenter(RspecCanvasLink.LinkCenter linkCenter) {
        assert pCanvas.getChildren().contains(linkCenter);
        pCanvas.getChildren().remove(linkCenter);
    }

    public void removeLinkCenter(GtsCanvasLink linkCenter) {
        assert pCanvas.getChildren().contains(linkCenter);
        pCanvas.getChildren().remove(linkCenter);
    }

    public enum CanvasStyle {
        /**
         * A canvas on which nodes can be added/removed/moved around.
         * <p/>
         * The default value
         */
        EDITABLE("canvas-editable"),
        /**
         * A canvas that represents a static environment. Typically for a running experiment
         */
        FIXED("canvas-fixed"),
        /**
         * A canvas that represents a static environment, but where a fatal error occured.
         */
        FAILED("canvas-failed"),
        /**
         * A canvas that represents a static environment where the resources are not available yet.
         */
        FUTURE_RESERVATION("canvas-future-reservation"),
        /**
         * A canvas that represents a static environment where the resources have expired.
         */
        EXPIRED("canvas-expired");


        private final String styleName;

        CanvasStyle(String styleName) {
            this.styleName = styleName;
        }

        public String getStyleName() {
            return styleName;
        }
    }

    public CanvasStyle getCanvasStyle() {
        return canvasStyle.get();
    }

    public ObjectProperty<CanvasStyle> canvasStyleProperty() {
        return canvasStyle;
    }

    public void setCanvasStyle(CanvasStyle canvasStyle) {
        this.canvasStyle.set(canvasStyle);
    }
}
