package leigh.mecha.nodegraph.javafx;

import de.michaelhoffer.DraggableNodePane;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.input.InputEvent;
import javafx.scene.input.MouseDragEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import leigh.mecha.javafx.JavaFXConstants;
import leigh.mecha.javafx.NodeRuler;
import leigh.mecha.log.MechaLogger;
import leigh.mecha.log.MechaLoggerFactory;
import leigh.mecha.nodegraph.NodeAttribute;
import leigh.mecha.nodegraph.NodeConfig;
import leigh.mecha.nodegraph.NodeConnection;
import leigh.mecha.nodegraph.NodeObject;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

/**
 * JavaFX implementation of a Node Graph editor.
 * <p>
 * The implementation consists of a root {@link Pane} which contains the canvas. The connection lines are drawn
 * directly onto the root pane. Within the pane, each Node (but not its connections) are represented by another child
 * {@link Pane}, which is draggable. The mechanics for drawing the connections are located within the
 * {@link SocketFlow} class.
 *
 * @author Alex Leigh
 * @since mk14 mod19 (CRYSTAL)
 */

// TODO Extract a useful interface which is not JavaFX-specific
// FIXME A bug exists where nulling a field should remove its local attribute and does not
// FIXME A bug exists where editing the nodegraph can leave dangling remote attributes (connections)
public class NodeGraphJFX {
    private static final MechaLogger logger = MechaLoggerFactory.getLogger(NodeGraphJFX.class);
    public final Pane root;
    private final EventHandler<MouseEvent> startHandler;
    private final ArrayList<SocketFlow> connections = new ArrayList<>();
    private final ArrayList<NodeObject> objects = new ArrayList<>();
    private final HashMap<Integer, ArrayList<Circle>> sockets = new HashMap<>();
    private final int socketRadius = 7;
    private final int nodeObjectPrefWidth = 170;
    private final int nodeObjectPrefHeight = 80;
    private final List<NodeConfig> nodesConfigs;
    private final ArrayList<GraphListener> listeners = new ArrayList<>();
    public Line line;
    private int idSequence = 0;
    // We stash the click location for the canvas here, since it gets lost in the event chain.
    private double canvasClickX;
    private double canvasClickY;

    /**
     * Create a new NodegraphGenerator. The root pane (the pane the connection lines will be created in) is passed
     * here.
     */
    public NodeGraphJFX(Pane root, List<NodeConfig> nodesConfigs) {
        this.nodesConfigs = nodesConfigs;
        class DragStartHandler implements EventHandler<MouseEvent> {
            @Override
            public void handle(MouseEvent event) {
                if (line == null) {
                    Node sourceNode = (Node) event.getSource();
                    line = new Line();

                    // We have to go up here to get to the NodePane.
                    Bounds bounds = socketRootBounds(sourceNode);

                    // start line at center of node
                    line.setStartX((bounds.getMinX() + bounds.getMaxX()) / 2);
                    line.setStartY((bounds.getMinY() + bounds.getMaxY()) / 2);
                    line.setEndX(line.getStartX());
                    line.setEndY(line.getStartY());

                    setupLine(line);

                    sourceNode.startFullDrag();
                    root.getChildren().add(0, line);
                }
            }
        }
        startHandler = new DragStartHandler();

        ContextMenu canvasMenu = buildCanvasMenu();

        root.setOnContextMenuRequested(new EventHandler<ContextMenuEvent>() {
            @Override
            public void handle(ContextMenuEvent evt) {
                logger.debug("Canvas context menu requested.");
                canvasClickX = evt.getScreenX();
                canvasClickY = evt.getScreenY();
                canvasMenu.show(root.getScene().getWindow(), evt.getScreenX(), evt.getScreenY());
            }
        });

        // This is a hack to ensure there is something to click on when the canvas first renders.
        root.setPrefHeight(JavaFXConstants.REALLY_TALL_HEIGHT);
        root.setPrefWidth(JavaFXConstants.REALLY_BIG_WIDTH);

        this.root = root;
    }

    /**
     * Set the line configuration for a draggable line.
     *
     * @param line
     */
    public static void setupLine(Line line) {
        line.setStrokeWidth(5);
    }

    /**
     * Given a node, return its root bounds (the bounds of its parent node).
     */
    public static Bounds socketRootBounds(Node socketCircle) {
        return socketCircle.getParent().localToParent(socketCircle.getBoundsInParent());
    }

    /**
     * Given a list of sockets, find the one which matches the given attribute name and input/output type.
     */
    public Circle findSocket(ArrayList<Circle> sockets, String attrName, boolean isInput) {
        logger.debug("findSocket: Called [attr: {}] [isInput: {}]", attrName, isInput);
        for (Circle socket : sockets) {
            SocketUserData ud = (SocketUserData) socket.getUserData();
            NodeAttribute attr = ud.getAttrCfg();

            logger.debug("findSocket: Searching for ud: {}", attr);

            if (attrName.equals(attr.getName())) {
                if (!isInput == ud.isOutput()) {
                    logger.debug("findSocket: Match!");
                    return socket;
                }
            }
        }

        logger.debug("findSocket: No match");
        return null;
    }

    /**
     * Given a set of node objects, load them into the editor.
     */
    public void load(List<NodeObject> nodes) {
        // Generate first so they all exist when we go to make the connections
        int hwm = 0;

        for (NodeObject node : nodes) {
            generate(node);
            int id = node.getId();
            if (id > hwm) hwm = id;
        }

        logger.debug("load(): Sockets count: {}", sockets.size());

        // The above did not create the connections, so we attempt to do that now.

        for (NodeObject node : nodes) {
            ArrayList<Circle> srcSockets = sockets.get(node.getId());

            logger.debug("Found node sockets: {}", srcSockets);

            for (NodeConnection connection : node.getConnections()) {
                try {
                    ArrayList<Circle> dstSockets = sockets.get(connection.getDstNode().getId());
                    logger.debug("Would have created connections: {}", connection);

                    Circle srcSocket = findSocket(srcSockets, connection.getSrcAttribute().getName(), true);
                    Circle dstSocket = findSocket(dstSockets, connection.getDstAttribute().getName(), false);

                    logger.debug("Located sockets for restored connection. [src: {}] [dst: {}]", srcSocket, dstSocket);

                    SocketFlow flow = new SocketFlow(this, srcSocket, dstSocket);
                    flow.setObj(node);
                    flow.setObjConnection(connection);
                    connections.add(flow);
                    flow.drawConnection(root);
                } catch (Exception e) {
                    // Intentionally blank.
                }
            }
        }

        idSequence = hwm + 1;
    }

    public ArrayList<GraphListener> getListeners() {
        return listeners;
    }

    protected void notifyUpdate() {
        for (GraphListener l : listeners) {
            l.updated();
        }
    }

    /**
     * Clear the UI contents of the graph, including the associated state.
     */
    public void clear() {
        connections.clear();
        objects.clear();
        root.getChildren().clear();
        sockets.clear();
        notifyUpdate();
    }

    /**
     * Return all the connections currently in this graph. Connections flow from source to destination.
     */
    public ArrayList<SocketFlow> getConnections() {
        return connections;
    }

    public int getNodeObjectPrefWidth() {
        return nodeObjectPrefWidth;
    }

    private ContextMenu buildCanvasMenu() {
        // Create ContextMenu
        ContextMenu menu = new ContextMenu();

        Menu newItem = new Menu("New");
        for (NodeConfig nodeConfig : nodesConfigs) {
            MenuItem nodeItem = new MenuItem(nodeConfig.getLabel());
            nodeItem.setOnAction(new EventHandler<ActionEvent>() {
                @Override
                public void handle(ActionEvent event) {
                    // We previously stashed the contextmenu location
                    Point2D coords = root.screenToLocal(canvasClickX, canvasClickY);
                    generate(nodeConfig, coords.getX(), coords.getY());
                }
            });
            newItem.getItems().add(nodeItem);
        }
        menu.getItems().add(newItem);

        MenuItem dumpItem = new MenuItem("Dump");
        dumpItem.setOnAction(event -> {
            for (NodeObject obj : objects) {
                logger.debug("[obj: {}]", obj);
            }
        });
        menu.getItems().add(dumpItem);


        return menu;
    }

    /**
     * Remove the current drag line.
     */
    private void removeDragLine() {
        if (line != null) {
            root.getChildren().remove(line);
            line = null;
        }
    }

    public ArrayList<NodeObject> getObjects() {
        return objects;
    }

    /**
     * Given the provided node Configuration, return a JFX {@link Pane} containing the GUI for the object. The Pane will
     * not have been added to the root node (this is the responsibility of the caller). This is done to allow
     * layout operations to occur elsewhere.
     * <p>
     * The userdata for the Pane will contain a {@link NodeObject}.
     * <p>
     * If the node already has attribute values, they will be populated into the UI. If the node has connections,
     * they will NOT be populated. This is because connections cannot be present until all the nodes are present
     * in the graph, as the destination may not yet have been created. Consequently, the connections must be created
     * as a separate tep.
     */

    public Pane generate(NodeObject obj) {
        DraggableNodePane node = new DraggableNodePane();
        objects.add(obj);
        node.setUserData(obj);
        node.setTranslateX(obj.getPosX());
        node.setTranslateY(obj.getPosY());

        DropShadow shadow = new DropShadow();
        node.setEffect(shadow);

        ContextMenu nodeContextMenu = new ContextMenu();
        MenuItem menuItem = new MenuItem("Delete");
        menuItem.setOnAction(event -> {
            ArrayList<SocketFlow> pending = new ArrayList<>();

            for (SocketFlow flow : connections) {
                NodeObject srcObj = (NodeObject) flow.getSrc().getParent().getUserData();
                NodeObject dstObj = (NodeObject) flow.getDst().getParent().getUserData();

                if (flow.getDst().getParent() == node || flow.getSrc().getParent() == node) {
                    logger.debug("Found remote connection to remove. [src: {}] [dst: {}]",
                            srcObj.getNodeConfig().getName(), dstObj.getNodeConfig().getName());

                    // FIXME: This is sort of uselessly side-effecting a call to notifyUpdate() which we call later
                    flow.removeConnection(root);
                    pending.add(flow);

                    ArrayList<NodeConnection> toRemove = new ArrayList<>();
                    for (NodeConnection con : srcObj.getConnections()) {
                        if (con.getDstNode() == obj) {
                            toRemove.add(con);
                        }
                    }
                    logger.debug("Removing from obj. [sz: {}]", toRemove.size());
                    srcObj.getConnections().removeAll(toRemove);
                }
            }

            logger.debug("Removing connections. [sz: {}] [pending: {}]", pending.size(), pending);
            root.getChildren().remove(node);
            objects.remove(obj);
            sockets.remove(obj.getId());

            for (SocketFlow flow : pending) {
                removeConnection(flow);
            }

            // TODO: Redundant
            notifyUpdate();
        });

        nodeContextMenu.getItems().add(menuItem);

        node.setOnContextMenuRequested(evt -> {
            // We do this so the context menu vanishes when we click outside of it.
            nodeContextMenu.show(node.getScene().getWindow(), evt.getScreenX(), evt.getScreenY());
            evt.consume();
        });

        EventHandler<InputEvent> nde = evt -> {
            // We moved - so re-draw all our connections at the new location.
            for (SocketFlow flow : connections) {
                flow.drawConnection(root);
            }
        };

        node.addEventHandler(MouseEvent.MOUSE_DRAGGED, nde);
        node.setPrefSize(nodeObjectPrefWidth, nodeObjectPrefHeight);
        VBox vbox = new VBox();

        // FIXME: We do not want to hard-code the font settings here, move to CSS / classes instead

        //vbox.setStyle("-fx-background-color: #a0a0a0;-fx-padding:5;");
        vbox.getStyleClass().add("leigh-mecha-ng-node");

        Label title = new Label(obj.getNodeConfig().getLabel());
        //title.setStyle("-fx-font-weight: bold;");
        //title.setFont(Font.font("Verdana", FontWeight.BOLD, 12));

        title.getStyleClass().add("leigh-mecha-ng-node-title");

        double runningHeight = NodeRuler.measure(title).getHeight();

        vbox.getChildren().add(title);

        // A list of all the circles (sockets) for this node. The userdata for the circle will contain the
        // attribute configuration.
        ArrayList<Circle> sockets = new ArrayList<>();

        for (NodeAttribute attr : obj.getNodeConfig().getAttrs()) {
            VBox fieldGroup = new VBox();
            Label label = new Label(attr.getLabel());
            TextField tf = new TextField();

            // We may already have a value from saved state, so populate it if so.
            String inputValue = obj.getValue(attr.getName());
            if (inputValue != null) {
                tf.setText(inputValue);
            }

            tf.textProperty().addListener(new ChangeListener<String>() {
                @Override
                public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
                    logger.debug("Attribute textfield change listener called...");
                    obj.setValue(attr.getName(), newValue);
                    notifyUpdate();
                }
            });

            fieldGroup.getChildren().addAll(label, tf);

            Bounds b = NodeRuler.measure(fieldGroup);
            // For some reason - this is calculating too high

            double adjustedHeight = b.getHeight() - 5.25;

            vbox.getChildren().add(fieldGroup);

            if (attr.isHasInputSocket()) {
                Circle inputCircle = new Circle(socketRadius);
                inputCircle.getStyleClass().add("leigh-mecha-ng-input-socket");

                inputCircle.relocate(-socketRadius, runningHeight + (adjustedHeight / 2));
                inputCircle.setUserData(new SocketUserData(attr, false));
                sockets.add(inputCircle);
            }

            if (attr.isHasOutputSocket()) {
                Circle outputCircle = new Circle(socketRadius);
                outputCircle.getStyleClass().add("leigh-mecha-ng-output-socket");

                outputCircle.relocate(b.getWidth() + socketRadius - 3, runningHeight + (adjustedHeight / 2));
                outputCircle.setUserData(new SocketUserData(attr, true));
                sockets.add(outputCircle);
            }

            runningHeight += adjustedHeight;
        }

        // We store these for later.
        this.sockets.put(obj.getId(), sockets);

        // This is for the connections, not the node.
        EventHandler<MouseDragEvent> dragReleaseHandler = evt -> {
            // Prevent circular references
            if (evt.getGestureSource() == evt.getSource()) {
                logger.debug("drag source & target are identical");
                evt.consume();
                return;
            }

            // We remove the line we were drawing, and replace it with a new line managed by this class.
            SocketFlow flow = new SocketFlow(this);

            if (flow.isValid(evt)) {
                for (SocketFlow f : connections) {
                    if (f.getSrc() == flow.getSrc() && f.getDst() == flow.getDst()) {
                        // This is a duplicate of a flow we already have
                        logger.warn("Attempt to create duplicate connection.");
                        evt.consume();
                        return;
                    }
                }

                logger.debug("Valid flow: {}", flow);
                connections.add(flow);

                NodeObject srcObj = (NodeObject) flow.getSrc().getParent().getUserData();
                NodeObject dstObj = (NodeObject) flow.getDst().getParent().getUserData();
                SocketUserData srcSocket = (SocketUserData) flow.getSrc().getUserData();
                SocketUserData dstSocket = (SocketUserData) flow.getDst().getUserData();

                NodeConnection connection = new NodeConnection(srcObj, srcSocket.getAttrCfg(),
                        dstObj, dstSocket.getAttrCfg());

                // This must be undone if the flow is removed
                obj.getConnections().add(connection);
                flow.setObj(obj);
                flow.setObjConnection(connection);

                logger.debug("Built connection: [src: {}] [dst:{}]", srcObj, dstObj);
                notifyUpdate();

                flow.drawConnection(root);
            } else {
                // We have an invalid flow, do nothing
            }

            evt.consume();
        };

        EventHandler<MouseEvent> socketDragEnteredHandler = evt -> {
            if (line != null) {
                // snap line end to node center
                Node nd = (Node) evt.getSource();
                Bounds bounds = nd.getBoundsInParent();
                line.setEndX((bounds.getMinX() + bounds.getMaxX()) / 2);
                line.setEndY((bounds.getMinY() + bounds.getMaxY()) / 2);
            }
            evt.consume();
        };

        EventHandler<MouseEvent> socketMouseDraggedHandler = evt -> {
            // This exists to prevent the event from bubbling up to the NodePane
            if (line != null) {
                // X & Y in the event are going to be relative to the circle
                Node nd = (Node) evt.getSource();
                Bounds bounds = nd.getParent().localToParent(nd.getBoundsInParent());

                line.setEndX(bounds.getCenterX() + evt.getX());
                line.setEndY(bounds.getCenterY() + evt.getY());
            }
            evt.consume();
        };

        for (Node n : sockets) {
            // register handlers
            n.setOnDragDetected(startHandler);
            n.setOnMouseDragReleased(dragReleaseHandler);
            n.setOnMouseDragEntered(socketDragEnteredHandler);
            n.setOnMouseDragged(socketMouseDraggedHandler);
        }

        root.setOnMouseReleased(evt -> {
            // TODO: This is getting called regardless of dragReleaseHandler
            // mouse released outside of a target -> remove line
            removeDragLine();
        });

        root.setOnMouseDragged(evt -> {
            if (line != null) {
                // If we are dragging - keep dragging.
                line.setEndX(evt.getX());
                line.setEndY(evt.getY());
                evt.consume();
            }
            // We don't consume so we can bubble up to maybe a ScrollPane if we are not currently dragging a connection
        });

        EventHandler<MouseEvent> paneDragExited = new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent event) {
                updateNodePosition(node);
                notifyUpdate();
            }
        };
        node.addEventHandler(MouseEvent.MOUSE_DRAGGED, paneDragExited);

        node.getChildren().add(vbox);
        // We add the sockets (the circles) last so they have the highest z-order.
        // We add them to the node pane so that they drag with the pane.
        node.getChildren().addAll(sockets);

        root.getChildren().add(node);

        notifyUpdate();

        return node;
    }

    /**
     * Generate a new node based on the provided configuration and the desired x/y position. The x/y position
     * is relative to the graph.
     */
    public Pane generate(NodeConfig nodeCfg, double xPos, double yPos) {
        NodeObject obj = new NodeObject(idSequence++, nodeCfg);
        obj.setPosX(xPos);
        obj.setPosY(yPos);
        return generate(obj);
    }

    /**
     * Update the stored x/y position for the given node, based on its movement in the nodegraph, typically as the
     * result of user interaction. The x/y position is relative to the graph.
     */
    public void updateNodePosition(Node node) {
        Bounds newBounds = node.getBoundsInParent();
        NodeObject obj = (NodeObject) node.getUserData();

        // Hacky fix for QOS-36. We get negative values from getBoundsInParent() so we add a correction here.
        obj.setPosX((int) newBounds.getMinX() + 38);
        obj.setPosY((int) newBounds.getMinY() + 38);
    }

    /**
     * Indicate that the given connection is being removed from the nodegraph.
     */
    public void removeConnection(SocketFlow flow) {
        connections.remove(flow);

        // The object that is being removed.

        NodeObject removedObj = flow.getObj();
        removedObj.getConnections().remove(flow.getObjConnection());

        notifyUpdate();
    }
}
