import React from "react";
import PropTypes, { InferType } from "prop-types";
import { DynamicGraph } from "./graph";
import { Node } from "../common/graph/node";
import { ForceGraph2D } from "react-force-graph";
import { Link } from "../common/graph/link";
import { GraphElement } from "../common/graph/graphelement";
import { Coordinate2D } from "../common/graph/graph";

export class GraphRenderer2D extends React.PureComponent<
    InferType<typeof GraphRenderer2D.propTypes>,
    InferType<typeof GraphRenderer2D.stateTypes>
> {
    private maxDistanceToConnect = 15;
    private defaultWarmupTicks = 100;
    private warmupTicks = 100;
    private forceGraph: React.RefObject<any>; // using typeof ForceGraph3d produces an error here...

    /**
     * True, if the graph was the target of the most recent click event.
     */
    private graphInFocus = false; // TODO: Remove?
    /**
     * True for each key, that is currently considered pressed. If key has not been pressed yet, it will not exist as dict-key.
     */
    private keys: { [name: string]: boolean };

    static propTypes = {
        graph: PropTypes.instanceOf(DynamicGraph).isRequired,
        width: PropTypes.number.isRequired,
        onNodeClicked: PropTypes.func,
        onNodeSelectionChanged: PropTypes.func,
        /**
         * Collection of all currently selected nodes. Can also be undefined or empty.
         */
        selectedNodes: PropTypes.arrayOf(PropTypes.instanceOf(Node)),
    };

    static stateTypes = {};

    constructor(props: InferType<typeof GraphRenderer2D.propTypes>) {
        super(props);

        this.handleNodeClick = this.handleNodeClick.bind(this);
        this.handleEngineStop = this.handleEngineStop.bind(this);
        this.handleNodeDrag = this.handleNodeDrag.bind(this);
        this.handleElementRightClick = this.handleElementRightClick.bind(this);
        this.screen2GraphCoords = this.screen2GraphCoords.bind(this);
        this.handleNodeCanvasObject = this.handleNodeCanvasObject.bind(this);
        this.handleLinkCanvasObject = this.handleLinkCanvasObject.bind(this);

        document.addEventListener("keydown", (e) => {
            this.keys[e.key] = true;
            this.handleShortcutEvents(e.key);
        });
        document.addEventListener("keyup", (e) => {
            this.keys[e.key] = false;
            this.handleShortcutEvents(e.key);
        });
        document.addEventListener(
            "mousedown",
            (e) => (this.graphInFocus = false)
        );

        this.state = {
            selectedNodes: [], // TODO: Why was undefined allowed here?
        };

        this.keys = {};
        this.forceGraph = React.createRef();
    }

    /**
     * Deletes all nodes currently selected. Handles store points accordingly of the number of deleted nodes.
     */
    private deleteSelectedNodes() {
        const selectedNodes = this.state.selectedNodes;

        if (selectedNodes.length == 1) {
            selectedNodes[0].delete();
            selectedNodes.pop();
            this.props.onNodeSelectionChanged(selectedNodes);
        } else {
            selectedNodes.forEach((node: Node) => node.delete());
            this.props.onNodeSelectionChanged([]);
        }
    }

    /**
     * Triggers actions that correspond with certain shortcuts.
     *
     * @param key Newly pressed key.
     */
    private handleShortcutEvents(key: string) {
        if (key === "Escape") {
            this.props.onNodeSelectionChanged([]);
        } else if (
            key === "Delete" &&
            this.graphInFocus // Only delete if 2d-graph is the focused element
        ) {
            this.deleteSelectedNodes();
        }
    }

    private handleNodeClick(node: Node) {
        this.graphInFocus = true;

        if (this.keys["Control"]) {
            // Connect to clicked node as parent while control is pressed
            if (this.state.selectedNodes.length == 0) {
                // Have no node connected, so select
                this.props.onNodeSelectionChanged([node]);
            } else if (!this.state.selectedNodes.includes(node)) {
                // Already have *other* node/s selected, so connect
                this.connectSelectionToNode(node);
            }
        } else if (this.keys["Shift"]) {
            this.toggleNodeSelection(node);
        } else {
            // By default, simply select node
            this.props.onNodeSelectionChanged([node]);
        }
        //this.forceUpdate(); // TODO: Remove?
    }

    /**
     * Handler for background click event on force graph. Adds new node by default.
     * @param event Click event.
     */
    private handleBackgroundClick(
        event: MouseEvent,
        position: { graph: Coordinate2D; window: Coordinate2D }
    ) {
        this.graphInFocus = true;

        // Is there really no node there? Trying to prevent small error, where this event is triggered, even if there is a node.
        const nearestNode = this.props.graph.getClosestNode(
            position.graph.x,
            position.graph.y
        );
        if (nearestNode !== undefined && nearestNode.distance < 4) {
            this.handleNodeClick(nearestNode.node);
            return;
        }

        // Just deselect if control key is pressed
        if (this.keys["Control"]) {
            this.props.onNodeSelectionChanged([]);
            return;
        }

        // Add new node
        const node = this.state.graph.createNode(
            undefined,
            position.graph.x,
            position.graph.y,
            0,
            0
        );
        this.forceUpdate(); // TODO: Remove?

        // Select newly created node
        if (this.keys["Shift"]) {
            // Simply add to current selection of shift is pressed
            this.toggleNodeSelection(node);
        } else {
            this.props.onNodeSelectionChanged([node]);
        }
    }

    /**
     * Processes right-click event on graph elements by deleting them.
     */
    private handleElementRightClick(element: GraphElement<unknown, unknown>) {
        this.graphInFocus = true;

        element.delete();
        this.forceUpdate(); // TODO: Necessary?
    }

    /**
     * Propagates the changed state of the graph.
     */
    private onGraphDataChange() {
        const nodes: Node[] = this.props.selectedNodes.map((node: Node) =>
            this.props.graph.node(node.id)
        );
        this.props.onNodeSelectionChanged(nodes);
        this.forceUpdate(); // TODO
    }

    private connectSelectionToNode(node: Node) {
        if (this.props.selectedNodes.length == 0) {
            return;
        }

        if (this.props.selectedNodes.length == 1) {
            node.connect(this.state.selectedNodes[0]);
        } else {
            this.props.selectedNodes.forEach((selectedNode: Node) =>
                node.connect(selectedNode)
            );
        }
    }

    private toggleNodeSelection(node: Node) {
        // Convert selection to array as basis
        let selection = this.props.selectedNodes;

        // Add/Remove node
        if (selection.includes(node)) {
            // Remove node from selection
            selection = selection.filter((n: Node) => !n.equals(node));
        } else {
            // Add node to selection
            selection.push(node);
        }
        this.props.onNodeSelectionChanged(selection);
    }

    private handleEngineStop() {
        // Only do something on first stop for each graph
        if (this.warmupTicks <= 0) {
            return;
        }

        this.warmupTicks = 0; // Only warm up once, so stop warming up after the first freeze
    }

    private handleNodeDrag(node: Node) {
        this.graphInFocus = true;

        if (
            !this.state.selectedNodes ||
            !this.state.selectedNodes.includes(node)
        ) {
            this.props.onNodeSelectionChanged([node]);
        }

        // Should run connect logic?
        if (!this.state.connectOnDrag) {
            return;
        }

        const closest = this.state.graph.getClosestNode(node.x, node.y, node);

        // Is close enough for new link?
        if (closest.distance > this.maxDistanceToConnect) {
            return;
        }

        // Does link already exist?
        if (node.neighbors.includes(closest.node)) {
            return;
        }

        // Add link
        node.connect(closest.node);
        // this.forceUpdate(); TODO: Remove?
    }

    private handleNodeCanvasObject(
        node: Node,
        ctx: CanvasRenderingContext2D,
        globalScale: number
    ) {
        // TODO: Refactor

        // add ring just for highlighted nodes
        if (this.props.selectedNodes.includes(node)) {
            // Outer circle
            ctx.beginPath();
            ctx.arc(node.x, node.y, 4 * 0.7, 0, 2 * Math.PI, false);
            ctx.fillStyle = "white";
            ctx.fill();

            // Inner circle
            ctx.beginPath();
            ctx.arc(node.x, node.y, 4 * 0.3, 0, 2 * Math.PI, false);
            ctx.fillStyle = node.type.color;
            ctx.fill();
        }

        // Draw image
        const imageSize = 12;
        if (node.icon !== undefined) {
            const img = new Image();
            img.src = node.icon;

            ctx.drawImage(
                img,
                node.x - imageSize / 2,
                node.y - imageSize / 2,
                imageSize,
                imageSize
            );
        }

        // Draw label
        /**
         * Nothing selected? => Draw all labels
         * If this nodes is considered highlighted => Draw label
         * If this node is a neighbor of a selected node => Draw label
         */
        // TODO: Reenable node label rendering
        // const isNodeRelatedToSelection: boolean =
        //     this.state.selectedNodes.length != 0 ||
        //     this.isHighlighted(node) ||
        //     this.selectedNodes.some((selectedNode: Node) =>
        //         selectedNode.neighbors.includes(node)
        //     );
        //
        // if (this.state.visibleLabels && isNodeRelatedToSelection) {
        //     const label = node.name;
        //     const fontSize = 11 / globalScale;
        //     ctx.font = `${fontSize}px Sans-Serif`;
        //     const textWidth = ctx.measureText(label).width;
        //     const bckgDimensions = [textWidth, fontSize].map(
        //         (n) => n + fontSize * 0.2
        //     ); // some padding
        //
        //     const nodeHeightOffset = imageSize / 3 + bckgDimensions[1];
        //     ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
        //     ctx.fillRect(
        //         (node as any).x - bckgDimensions[0] / 2,
        //         (node as any).y - bckgDimensions[1] / 2 + nodeHeightOffset,
        //         ...bckgDimensions
        //     );
        //
        //     ctx.textAlign = "center";
        //     ctx.textBaseline = "middle";
        //     ctx.fillStyle = "white";
        //     ctx.fillText(
        //         label,
        //         (node as any).x,
        //         (node as any).y + nodeHeightOffset
        //     );
        // }

        // TODO: Render label as always visible
    }

    private handleLinkCanvasObject(
        link: Link,
        ctx: CanvasRenderingContext2D,
        globalScale: number
    ) {
        // Links already initialized?
        if (link.source.x === undefined) {
            return;
        }

        // Draw gradient link
        const gradient = ctx.createLinearGradient(
            link.source.x,
            link.source.y,
            link.target.x,
            link.target.y
        );
        // Have reversed colors
        // Color at source node referencing the target node and vice versa
        gradient.addColorStop(0, link.target.type.color);
        gradient.addColorStop(1, link.source.type.color);

        let lineWidth = 0.5;
        if (
            this.props.selectedNodes.some((node: Node) =>
                node.links.find(link.equals)
            )
        ) {
            lineWidth = 2;
        }
        lineWidth /= globalScale; // Scale with zoom

        ctx.beginPath();
        ctx.moveTo(link.source.x, link.source.y);
        ctx.lineTo(link.target.x, link.target.y);
        ctx.strokeStyle = gradient;
        ctx.lineWidth = lineWidth;
        ctx.stroke();
    }

    public screen2GraphCoords(x: number, y: number): Coordinate2D {
        return this.forceGraph.current.screen2GraphCoords(x, y);
    }

    /**
     * Calculates the corresponding coordinates for a click event for easier further processing.
     * @param event The corresponding click event.
     * @returns Coordinates in graph and coordinates in browser window.
     */
    private extractPositions(event: any): {
        graph: Coordinate2D;
        window: Coordinate2D;
    } {
        return {
            graph: this.screen2GraphCoords(
                event.layerX, // TODO: Replace layerx/layery non standard properties and fix typing
                event.layerY
            ),
            window: { x: event.clientX, y: event.clientY },
        };
    }

    render() {
        this.warmupTicks = this.defaultWarmupTicks;
        return (
            <ForceGraph2D
                ref={this.forceGraph}
                width={this.props.width}
                graphData={this.props.graph}
                onNodeClick={this.handleNodeClick}
                autoPauseRedraw={false}
                cooldownTicks={0}
                warmupTicks={this.warmupTicks}
                onEngineStop={this.handleEngineStop}
                nodeCanvasObject={this.handleNodeCanvasObject}
                nodeCanvasObjectMode={() => "after"}
                linkCanvasObject={this.handleLinkCanvasObject}
                linkCanvasObjectMode={() => "replace"}
                nodeColor={(node: Node) => node.type.color}
                onNodeDrag={this.handleNodeDrag}
                onLinkRightClick={this.handleElementRightClick}
                onNodeRightClick={this.handleElementRightClick}
                onBackgroundClick={(event: any) =>
                    this.handleBackgroundClick(
                        event,
                        this.extractPositions(event)
                    )
                }
            />
        );
    }
}