import React, {useState, useRef, useEffect} from 'react';
import PropTypes from 'prop-types';
import {TransitionGroup, CSSTransition} from 'react-transition-group';
import Springy from 'springy';
import {Relation} from '../data/model.js';

/**
 * Échappe une valeur utilisée dans un sélecteur d’attributs CSS.
 *
 * @param value Valeur à échapper.
 * @return Valeur échappée.
 */
const escapeAttrValue = value => value.replace(/"/g, '\\"');

/**
 * Crée un identifiant unique pour l’arête identifiée par ses deux extrémités.
 *
 * @param from Premier nœud de l’arête.
 * @param to Second nœud de l’arête.
 * @return Identifiant unique pour les deux directions de l’arête.
 */
const getEdgeId = (from, to) =>
{
    if (to < from)
    {
        [to, from] = [from, to];
    }

    return JSON.stringify([from, to]);
};

/**
 * Recherche parmi les descendants d’un élément celui qui représente un
 * nœud donnée, s’il existe.
 *
 * @param parent Élément parent.
 * @param id Identifiant du nœud recherché.
 * @return Élément trouvé ou null sinon.
 */
const findNode = (parent, id) =>
    parent.querySelector(`[data-node-id="${escapeAttrValue(id)}"]`);

/**
 * Recherche parmi les descendants d’un élément celui qui représente une
 * arête donnée, s’il existe.
 *
 * @param parent Élément parent.
 * @param id Identifiant de l’arête recherchée.
 * @return Élément trouvé ou null sinon.
 */
const findEdge = (parent, id) =>
    parent.querySelector(`[data-edge-id="${escapeAttrValue(id)}"]`);

/**
 * Recherche le premier élément parent représentant un nœud.
 *
 * @param start Élément de départ.
 * @return Élément trouvé ou null sinon.
 */
const findParentNode = start =>
{
    if (start instanceof window.HTMLDocument)
    {
        return null;
    }

    if (start.hasAttribute('data-node-id'))
    {
        return start;
    }

    return findParentNode(start.parentNode);
};

/**
 * Récupère le point central du graphe.
 */
const graphCenter = () => new Springy.Vector(
    window.innerWidth / 2,
    window.innerHeight / 2
);

/**
 * Échelle du graphe.
 */
const graphScale = 50;

/**
 * Convertit des coordonnées dans le référentiel du graphe vers les coordonnées
 * correspondantes dans le référentiel de l’écran.
 *
 * @param vec Coordonnées dans le référentiel du graphe.
 * @return Coordonnées dans le référentiel de l’écran.
 */
const coordsToScreen = vec => vec.multiply(graphScale).add(graphCenter());

/**
 * Convertit des coordonnées dans le référentiel de l’écran vers les
 * coordonnées correspondantes dans le référentiel du graphe.
 *
 * @param vec Coordonnées dans le référentiel de l’écran.
 * @return Coordonnées dans le référentiel du graphe.
 */
const screenToCoords = vec => vec.subtract(graphCenter()).divide(graphScale);

/**
 * Affiche un graphe.
 *
 * @prop nodes Liste des identifiants de nœuds du graphe.
 * @prop edges Couples d’identifiants de nœuds formant les arêtes du graphe.
 * @prop emptyMessage Message affiché lorsque le graphe est vide.
 * @prop render Fonction de rendu prenant en paramètre l’identifiant d’un nœud
 * du graphe et renvoyant un élément à afficher pour le représenter.
 * @prop onNodeClick Fonction de rappel appelée lors du clic sur un nœud du
 * graphe avec l’identifiant du nœud correspondant.
 */
const Graph = ({
    nodes,
    edges,
    emptyMessage,
    render,
    onNodeClick,
}) =>
{
    const [graph] = useState(new Springy.Graph());
    const [layout] = useState(new Springy.Layout.ForceDirected(
        graph,
        /* rigidité des arêtes = */ 400,
        /* répulsion des nœuds = */ 450,
        /* amortissement de vitesse = */ 0.4
    ));

    // N’arrête jamais l’animation
    layout.minEnergyThreshold = 0;

    // Pointeur sur l’élément englobant le graphe
    const graphParent = useRef(null);

    // Ajout des nouveaux nœuds et retrait des anciens
    const oldNodes = new Set(Object.keys(graph.nodeSet));

    for (let node of nodes)
    {
        if (!oldNodes.has(node))
        {
            graph.addNode(new Springy.Node(node));
        }
    }

    for (let node of oldNodes)
    {
        if (!nodes.includes(node))
        {
            graph.removeNode({id: node});
        }
    }

    // Ajout des nouvelles arêtes et retrait des anciennes
    const newEdges = new Set(edges.map(edge => getEdgeId(...edge)));
    const oldEdges = new Set(graph.edges.map(edge => edge.id));

    for (let [from, to] of edges)
    {
        const edgeId = getEdgeId(from, to);

        if (!oldEdges.has(edgeId))
        {
            graph.addEdge(new Springy.Edge(edgeId, {id: from}, {id: to}));
        }
    }

    for (let edge of oldEdges)
    {
        if (!newEdges.has(edge))
        {
            graph.removeEdge({id: edge});
        }
    }

    // Rendu de l’animation du graphe
    useEffect(() =>
    {
        layout.start(() =>
        {
            layout.eachNode(({id}, {p}) =>
            {
                const element = findNode(graphParent.current, id);
                const {x, y} = coordsToScreen(p);

                element.style.transform = `translate(
                    calc(${x}px - 50%),
                    calc(${y}px - 50%)
                )`;
            });

            layout.eachEdge(({id}, {point1: {p: p1}, point2: {p: p2}}) =>
            {
                const element = findEdge(graphParent.current, id);
                const {x: x1, y: y1} = coordsToScreen(p1);
                const {x: x2, y: y2} = coordsToScreen(p2);

                element.setAttribute('x1', x1);
                element.setAttribute('y1', y1);
                element.setAttribute('x2', x2);
                element.setAttribute('y2', y2);
            });
        });
    }, []);

    // Gestion du déplacement des nœuds à la souris
    useEffect(() =>
    {
        // Identifiant du nœud en cours de déplacement
        let dragNode = -1;

        // Point physique correspondant au nœud en cours de déplacement
        let dragPoint = null;

        // Élément du DOM correspondant au nœud en cours de déplacement
        let dragElement = null;

        // Décalage entre le centre du nœud et le pointeur au moment du début
        // du déplacement du nœud courant
        let dragDelta = null;

        // Vrai si le déplacement compte comme un clic sur le nœud
        let dragClick = false;

        // Position originelle du nœud en cours de déplacement
        let dragOrigin = null;

        const mouseDown = ev =>
        {
            dragElement = findParentNode(ev.target);

            if (dragElement !== null)
            {
                const {clientX: x, clientY: y} = ev;
                const screen = new Springy.Vector(x, y);
                const coords = screenToCoords(screen);

                dragNode = dragElement.getAttribute('data-node-id');
                dragPoint = layout.nodePoints[dragNode];
                dragDelta = dragPoint.p.subtract(coords);
                dragClick = true;
                dragOrigin = screen;

                dragElement.classList.add('Graph_node-dragging');
                dragPoint.m = Infinity;
            }
        };

        const mouseMove = ev =>
        {
            if (dragElement !== null)
            {
                const {clientX: x, clientY: y} = ev;
                const screen = new Springy.Vector(x, y);
                const coords = screenToCoords(screen);

                dragPoint.p = dragDelta.add(coords);

                if (dragOrigin.subtract(screen).magnitude() >= 5)
                {
                    // Déplacement de plus de 5 px ⇒ pas de clic
                    dragClick = false;
                }
            }
        };

        const mouseUp = ev =>
        {
            if (dragElement !== null)
            {
                if (dragClick)
                {
                    onNodeClick(dragNode, ev);
                }

                dragNode = -1;

                dragPoint.m = 1;
                dragPoint = null;

                dragElement.classList.remove('Graph_node-dragging');
                dragElement = null;

                dragDelta = null;
                dragOrigin = null;
            }
        };

        graphParent.current.addEventListener('mousedown', mouseDown);
        document.body.addEventListener('mousemove', mouseMove);
        document.body.addEventListener('mouseup', mouseUp);

        return () =>
        {
            graphParent.current.removeEventListener('mousedown', mouseDown);
            document.body.removeEventListener('mousemove', mouseMove);
            document.body.removeEventListener('mouseup', mouseUp);
        };
    }, [onNodeClick]);

    return (
        <div ref={graphParent} className="Graph">
            <svg className="Graph_edgesContainer">
                <TransitionGroup component={null}>
                    {edges.map(edge => (
                        <CSSTransition
                            key={getEdgeId(...edge)}
                            timeout={500}
                            classNames="Graph_element"
                        >
                            <line
                                data-edge-id={getEdgeId(...edge)}
                                className="Graph_edge"
                            />
                        </CSSTransition>
                    ))}
                </TransitionGroup>
            </svg>

            <TransitionGroup component={null}>
                {nodes.map(id => (
                    <CSSTransition
                        key={id}
                        timeout={500}
                        classNames="Graph_element"
                    >
                        <span
                            data-node-id={id}
                            className="Graph_node"
                        >
                            {render(id)}
                        </span>
                    </CSSTransition>
                ))}
            </TransitionGroup>

            {nodes.length === 0
                ? <span className="Graph_empty">{emptyMessage}</span>
                : null}
        </div>
    );
};

Graph.propTypes = {
    nodes: PropTypes.arrayOf(PropTypes.any).isRequired,
    edges: PropTypes.arrayOf(Relation).isRequired,
    emptyMessage: PropTypes.string.isRequired,
    render: PropTypes.func.isRequired,
    onNodeClick: PropTypes.func.isRequired,
};

export default Graph;
