class Graph {
    constructor(dataUrl) {
        this.graph = null;
        this.gData = null;

        this.highlightNodes = new Set();
        this.highlightLinks = new Set();
        this.hoverNode = null;
        this.edgeColors = {};
        this.idToNode = {};

        this.firstTick = true;
        this.infooverlay = null;

        this.edgeTypeVisibility = {};

        this.loadGraph(dataUrl);

    }

    async loadGraph(dataUrl) {
        this.gData = await fetch(dataUrl).then(res => res.json())
        this.graph = ForceGraph3D({extraRenderers: [new THREE.CSS2DRenderer(), new THREE.CSS3DRenderer()]})
        (document.getElementById('3d-graph'))
            .graphData(this.gData)
            .nodeLabel('id')
            .nodeAutoColorBy('group')
            .nodeColor(node => this.getNodeColor(node))
            .linkWidth(link => this.getLinkWidth(link))
            .onNodeClick(node => {
                this.focusOnNode(node);
                this.infooverlay.updateInfoOverlay(node);
            })
            .onNodeHover(node => {
                this.onNodeHover(node);
                this.updateHighlight();
            })
            .onLinkHover(link => this.onLinkHover(link))
            .linkColor(link => this.getLinkColor(link))
            .linkOpacity(0.8)
            .nodeThreeObjectExtend(false)
            .nodeThreeObject(node => this.drawNode(node))
            .onEngineTick(() => this.initializeModel())
            .width(getWidth())
            .height(getHeight());
    }

    initializeModel() {
        if (this.firstTick) {
            this.mapEdgeColors();
            this.updateNodeData();
            this.updateNodeMap();
            this.addBackground();

            loadComponents();

            // Catch resize events
            document.addEventListener("fullscreenchange", () => this.resize());
            window.addEventListener("resize", () => this.resize());

            this.getLinkClasses().forEach(item => this.edgeTypeVisibility[item] = true);
            this.firstTick = false;
        }
    }

    getNodeColor(node) {
        return this.highlightNodes.has(node) ? node === hoverNode ? 'rgb(255,0,0,1)' : 'rgba(255,160,0,0.8)' : 'rgba(0,255,255,0.6)';
    }

    getLinkColor(link) {
        if ('type' in link) {
            return this.edgeColors[link.type]
        }
        return 'rgb(255, 255, 255)'
    }

    getLinkWidth(link) {
        return this.highlightLinks.has(link) ? 2 : 0.8;
    }

    getLinkClasses() {
        const linkClasses = [];
        this.graph.graphData().links.forEach(link => linkClasses.push(link.type));
        return [... new Set(linkClasses)];
    }

    onNodeHover(node) {
        // no state change
        if ((!node && !this.highlightNodes.size) || (node && this.hoverNode === node)) return;

        this.highlightNodes.clear();
        this.highlightLinks.clear();
        if (node) {
            this.highlightNodes.add(node);
            node.neighbors.forEach(neighbor => this.highlightNodes.add(neighbor));
            node.links.forEach(link => this.highlightLinks.add(link));
        }

        this.hoverNode = node || null;
    }

    onLinkHover(link) {
        this.highlightNodes.clear();
        this.highlightLinks.clear();

        if (link) {
            this.highlightLinks.add(link);
            this.highlightNodes.add(link.source);
            this.highlightNodes.add(link.target);
        }

        this.updateHighlight();
    }

    focusOnNode(node) {
        if (typeof node == "string") {
            node = this.idToNode[node];
        }

        // Aim at node from outside it
        const distance = 250;
        const distRatio = 1 + distance/Math.hypot(node.x, node.y, node.z);

        this.graph.cameraPosition(
            { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio }, // new position
            node, // lookAt ({ x, y, z })
            1000  // ms transition duration
        );
    }

    toggleLinkVisibility(type) {
        if (this.edgeTypeVisibility[type]) {
            this.hideLinkType(type);
        } else {
            this.showLinkType(type);
        }
    }

    hideLinkType(type) {
        this.edgeTypeVisibility[type] = false;
        this.updateGraphData();
        this.updateNodeData();
        this.removeFloatingNodes();
    }

    showLinkType(type) {
        this.edgeTypeVisibility[type] = true;
        this.updateGraphData();
        this.updateNodeData();
        this.removeFloatingNodes();
    }

    removeFloatingNodes() {
        const gData = this.graph.graphData();
        const nodes = gData.nodes.filter(node => node.neighbors.length > 0);
        const data = {
            nodes: nodes,
            links: gData.links
        };
        this.graph.graphData(data);
    }

    updateGraphData() {
        const data = {
            nodes: this.gData.nodes,
            links: this.gData.links.filter(l => this.edgeTypeVisibility[l.type])
        };
        this.graph.graphData(data);
    }

    resetNodeData() {
        const gData = this.graph.graphData();
        for (const node of gData.nodes) {
            node.neighbors = [];
            node.links = [];
        }
    }

    updateNodeData() {
        const gData = this.graph.graphData();
        // cross-link node objects
        this.resetNodeData();

        gData.links.forEach(link => {
            const a = link.source;
            const b = link.target;
            a.neighbors.push(b);
            b.neighbors.push(a);
            a.links.push(link);
            b.links.push(link);
        });
        this.graph.graphData(gData)
    }

    updateHighlight() {
        // trigger update of highlighted objects in scene
        this.graph
            .nodeColor(this.graph.nodeColor())
            .linkWidth(this.graph.linkWidth())
            .linkDirectionalParticles(this.graph.linkDirectionalParticles());
    }

    updateNodeMap() {
        const gData = this.graph.graphData();
        gData.nodes.forEach(node => {
            this.idToNode[node.id] = node;
        })
    }

    resize() {
        if(document.fullscreenElement == getCanvasDivNode()) {
            this.graph.height(screen.height);
            this.graph.width(screen.width);
        } else {
            this.graph.height(window.innerHeight - 200);
            this.graph.width(getWidth());
        }
    }

    addBackground() {
        const sphereGeometry = new THREE.SphereGeometry(20000, 32, 32);
        //const planeGeometry = new THREE.PlaneGeometry(1000, 1000, 1, 1);
        const loader = new THREE.TextureLoader();
        //const planeMaterial = new THREE.MeshLambertMaterial({color: 0xFF0000, side: THREE.DoubleSide}); //THREE.BackSide
        const planeMaterial = new THREE.MeshBasicMaterial({map: loader.load(plugin_path + 'backgrounds/background_4.jpg'), side: THREE.DoubleSide}); //THREE.BackSide
        const mesh = new THREE.Mesh(sphereGeometry, planeMaterial);
        mesh.position.set(0, 0, 0);
        //mesh.rotation.set(0.5 * Math.PI, 0, 0);

        this.graph.scene().add(mesh);
    }

    mapEdgeColors() {
        const linkClasses = this.getLinkClasses()
        for (let i = 0; i < linkClasses.length; i++) {
            this.edgeColors[linkClasses[i]] = COLOR_PALETTE[i % COLOR_PALETTE.length]
        }
    }

    drawNode(node) {
        // Draw node as label + image
        const nodeDiv = document.createElement('div');
        const group = new THREE.Group();

        const labelDiv = document.createElement('div')
        labelDiv.textContent = node.name;
        labelDiv.style.color = node.color;
        labelDiv.className = 'node-label';
        nodeDiv.appendChild(labelDiv);

        const cssobj = new THREE.CSS3DSprite(nodeDiv);
        cssobj.scale.set(0.25, 0.25, 0.25);
        cssobj.position.set(0, -6, 0);
        group.add(cssobj)

        // Draw node circle image
        const textureLoader = new THREE.TextureLoader();
        const imageAlpha = textureLoader.load(plugin_path + 'datasets/images/alpha.png');
        let imageTexture = null;

        if ('image' in node) {
            imageTexture = textureLoader.load(plugin_path + 'datasets/images/' + node.image);
        } else {
            imageTexture = textureLoader.load(plugin_path + 'datasets/images/default.jpg');
        }

        const material = new THREE.SpriteMaterial({map: imageTexture, alphaMap: imageAlpha, transparent: true,
            alphaTest: 0.2, depthWrite:false, depthTest: false});
        const sprite = new THREE.Sprite(material);
        sprite.renderOrder = 999; // This may not be optimal. But it allows us to render the sprite on top of everything else.

        if ('image' in node) {
            sprite.scale.set(20, 20);
        } else {
            sprite.scale.set(5, 5);
        }

        group.add(sprite)

        return group;
    }

}

function loadComponents() {
    linkoverlay.create();
    infooverlay.create();
    createFullScreenButton();
}


function createFullScreenButton() {
    const sceneNode = document.getElementById('3d-graph');
    const overlayNode = document.createElement('button');
    overlayNode.className = 'fullscreen_button';
    overlayNode.innerText = 'fullscreen';
    overlayNode.addEventListener("click", function () {
        if(getCanvasDivNode().requestFullscreen) {
            getCanvasDivNode().requestFullscreen().then(
                () => G.resize()
            );

        }
    });
    sceneNode.appendChild(overlayNode);
}


const dataUrl = plugin_path + 'datasets/aud1.json'
G = new Graph(dataUrl);
linkoverlay = new LinkOverlay(G);
infooverlay = new InfoOverlay(G);
G.infooverlay = infooverlay;