class BZgraflow extends Buildoz{

    constructor(){
        super()
        this.defaultAttrs = { tension: 100 }
        this.stagedNodes = { }
        this.stagedWires = { }
        this.arrowDefs = null
    }
    static _loadedNodeStyles = new Set() // Allow multi instances or re-loadNodes, but avoid reinjecting same styles !

    connectedCallback() { 
        super.connectedCallback()
        const flowUrl = this.getBZAttribute('flow')
        if(!flowUrl) {
            console.warn('BZgraflow: No flow URL !?')
            return
        }
        this.mainContainer  = document.createElement('div')
        this.mainContainer.classList.add('bzgf-main-container')
        this.shadow = this.mainContainer.attachShadow({ mode: 'open' })
        const style = document.createElement('style')
        style.textContent = `
            .bzgf-wires-container,
            .bzgf-nodes-container{ position: absolute; inset: 0; width: 100%; height: 100%; }
            .bzgf-nodes-container .bzgf-node{ position:absolute; }
            .bzgf-nodes-container .bzgf-fake-node{
                position: absolute;
                width: 5px;
                height: 5px;
                backgrround: transparent;
                border-style: none;
            }            
        `
        this.shadow.appendChild(style)
        this.nodesContainer = document.createElement('div')
        this.nodesContainer.classList.add('bzgf-nodes-container')
        this.wiresContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 
        this.wiresContainer.setAttribute('overflow','visible')
        this.wiresContainer.classList.add('bzgf-wires-container')
        this.shadow.append(this.wiresContainer)
        this.shadow.append(this.nodesContainer)
        this.append(this.mainContainer)
        this.loadFlow(flowUrl) // Let it load async 
    }
    
    error(msg, err){
        this.innerHTML = `<div style="background:red;color:black;margin: auto;width: fit-content;">${msg}</div>`
        if(err) console.error(msg, err)
        else console.error(msg)
    }

    async loadFlow(url){
        const res = await fetch(url+'?'+crypto.randomUUID())
        const buf = await res.text()
        let flowObj
        try{
            flowObj = JSON.parse(buf)
        } catch(err){
            this.error('Could not parse flow JSON!?', err)

            return
        }
        if(!flowObj.nodesFile){
            this.error('No nodesFile in JSON!?')
            return
        }
        await this.loadNodes(flowObj.nodesFile)
        this.flow = flowObj.flow
        this.refresh()
    }

    async loadNodes(url) {
        const res = await fetch(url+'?'+crypto.randomUUID())
        const html = await res.text()

        // Get nodes
        const doc = new DOMParser().parseFromString(html, 'text/html')
        this.nodesRegistry = {}
        for(const tpl of doc.querySelectorAll('template')){
            if(tpl.id=='svg-arrows'){
                this.arrowDefs = tpl.querySelector('defs').cloneNode(true)
                this.wiresContainer.appendChild(this.arrowDefs)
            } else {
                const rootEl = tpl.content.querySelector('.bzgf-node')
                if(!rootEl) continue
                this.nodesRegistry[rootEl.dataset.nodetype] = rootEl
            }
        }

        // Now load styles (once)
        if(!BZgraflow._loadedNodeStyles.has(url)) { 
            const styles = doc.querySelectorAll('style')
            styles.forEach(styleEl => {
                const style = document.createElement('style')
                style.textContent = styleEl.textContent
                this.shadow.appendChild(style)
            })
            BZgraflow._loadedNodeStyles.add(url)
        }
    }

    addNode(node){ 
        const id = node.id       
        if(!(node.nodeType in this.nodesRegistry)){ console.warn(`Unknown node type (${node.nodeType})`); return(null)}
        const nodeDef = this.nodesRegistry[node.nodeType]
        this.stagedNodes[id] = nodeDef.cloneNode(true)
        for(const token in node.markup){
                this.stagedNodes[id].innerHTML = this.stagedNodes[id].innerHTML.replace(new RegExp(`\{${token}\}`, 'g'), node.markup[token])
        }
        if(typeof(node.data)=='object') Object.assign(this.stagedNodes[id].dataset, node.data)
        const portEls = this.stagedNodes[id].querySelectorAll('.port')
        this.stagedNodes[id].style.left = (node.coords && node.coords.x) ? `${node.coords.x}px` : '10%'
        this.stagedNodes[id].style.top = (node.coords && node.coords.y) ? `${node.coords.y}px` : '10%'
        this.stagedNodes[id].dataset.id = id
        this.stagedNodes[id].ports = Object.fromEntries(Array.from(portEls).map(item => ([item.dataset.id, { ...item.dataset, el:item }])))
        this.nodesContainer.append(this.stagedNodes[id])
        return(this.stagedNodes[id])
    }

    addFakeNode(nid, x, y, w, h){
        this.stagedNodes[nid] = document.createElement('div')
        this.stagedNodes[nid].classList.add('bzgf-fake-node')
        this.stagedNodes[nid].style.left = `${x}px`
        this.stagedNodes[nid].style.top = `${y}px`
        this.stagedNodes[nid].style.width = `${w}px`
        this.stagedNodes[nid].style.height = `${h}px`
        this.stagedNodes[nid].dataset.id = nid
        this.nodesContainer.append(this.stagedNodes[nid])
        return(this.stagedNodes[nid])
    }

    addWire(link){
        const [idNode1, idPort1] = link.from
        const [idNode2, idPort2] = link.to
        const path = this.bezierNodes(idNode1, idPort1, idNode2, idPort2, this.getBZAttribute('tension'))
        const id  = `${idNode1}_${idNode2}`
        this.stagedWires[id] = document.createElementNS('http://www.w3.org/2000/svg', 'path')
        this.stagedWires[id].setAttribute('d', path)
        this.stagedWires[id].setAttribute('fill', 'none')
        if(this.arrowDefs && link.endArrow) this.stagedWires[id].setAttribute('marker-end','url(#arrow)')
        if(this.arrowDefs && link.startArrow) this.stagedWires[id].setAttribute('marker-start','url(#arrow)')
        this.stagedWires[id].classList.add('bzgf-wire')
        this.stagedWires[id].dataset.id = id
        this.wiresContainer.append(this.stagedWires[id])
        return(this.stagedWires[id])
    }


    clear(){
        this.nodesContainer.innerHTML = ''
        this.wiresContainer.innerHTML = ''
        if(this.arrowDefs) this.wiresContainer.appendChild(this.arrowDefs)
    }

    refresh(){
        this.clear()
        let forceAutoplace = false
        for(const node of this.flow.nodes){
            if((!node.coords) || (!node.coords.x) ||(!node.coords.y)) forceAutoplace=true
            this.addNode(node)
        } 
        for(const link of this.flow.links){
            this.addWire(link)
        }
        
        if(forceAutoplace){
            const bb=this.getBoundingClientRect()
            //TODO compute tensions from ports
            if(bb.width > bb.height) this.autoPlace('horizontal', 80, 30, 500)
            else this.autoPlace('vertical', 80, 30, 200)    
        } 
    }

    bezierNodes(idNode1, idPort1, idNode2, idPort2, tensionMin=60) {
        const svgRect = this.wiresContainer.getBoundingClientRect()
        const node1 = this.stagedNodes[idNode1]
        const port1 = node1.ports[idPort1]
        const node2 = this.stagedNodes[idNode2]
        const port2 = node2.ports[idPort2]
        if(!node1 || !node2 || !port1 || !port2) {
            console.warn('Link on bad node / port ', idNode1, idPort1, idNode2, idPort2)
            return('')
        }
        const bb1 = port1.el.getBoundingClientRect()
        const bb2 = port2.el.getBoundingClientRect()
        const x1 = Math.floor(bb1.x + (bb1.width/2)) - svgRect.left
        const y1 = Math.floor(bb1.y + (bb1.height/2)) - svgRect.top
        const x2 = Math.floor(bb2.x + (bb2.width/2)) - svgRect.left
        const y2 = Math.floor(bb2.y + (bb2.height/2)) - svgRect.top
        const loop = (idNode1==idNode2) && (idPort1==idPort2)
        const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1)
        let tension = dist * 0.4
        if(tension < tensionMin) tension = parseInt(tensionMin)

        const dirVect = {
            n: { x:  0, y: -1 },
            s: { x:  0, y:  1 },
            e: { x:  1, y:  0 },
            w: { x: -1, y:  0 },
        }
        let c1x = x1 + (dirVect[port1.direction].x * tension)
        let c1y = y1 + (dirVect[port1.direction].y * tension)
        let c2x = x2 + (dirVect[port2.direction].x * tension)
        let c2y = y2 + (dirVect[port2.direction].y * tension)
        if(loop){ 
            if(['n', 's'].includes(port1.direction)) {
                c1x += tension
                c2x -= tension
            }
            if(['e', 'w'].includes(port1.direction)){
                c1y += 1*tension
                c2y -= 1*tension
            }
        } 
        return(`M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`)
    }

    bezierInterNodes(idNode1, idPort1, idNode2, idPort2, interNodes, orientation='horizontal', tensionMin=60) {
        const svgRect = this.wiresContainer.getBoundingClientRect()
        const makeCubicBezier = (x1, y1, x2, y2, orientation) => {
            const dist = Math.abs(x2 - x1) + Math.abs(y2 - y1)
            let tension = dist * 0.4
            if(tension < tensionMin) tension = parseInt(tensionMin)     
            let c1x, c1y, c2x, c2y       
            if(orientation=='horizontal'){
                c1x = x1 + tension
                c1y = y1 
                c2x = x2 - tension
                c2y = y2 
            } else {
                c1x = x1 
                c1y = y1 + tension
                c2x = x2 
                c2y = y2 - tension                
            }
            return(`C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`)
        }
        const directPath = this.bezierNodes(idNode1, idPort1, idNode2, idPort2, tensionMin)
        const startPath = directPath.substring(0,directPath.indexOf('C'))
        const endPath = directPath.substring(directPath.lastIndexOf(',')+1).trim()
        let path = startPath
        let [ , x1, y1] = startPath.split(' ')
        x1 = parseInt(x1)
        y1 = parseInt(y1)
        for(const interNode of interNodes){
            const bb = this.stagedNodes[interNode].getBoundingClientRect()
            let x2; let y2;
            if(orientation=='horizontal'){
                x2 = bb.x -svgRect.left
                y2 =Math.floor(bb.y + (bb.height/2)) - svgRect.top
                path += makeCubicBezier(x1, y1, x2, y2, orientation)
                x2 += bb.width
                path += ` L ${x2} ${y2} `
            } else {
                x2 = Math.floor(bb.x + (bb.width/2)) - svgRect.left
                y2 = bb.y - svgRect.top
                path += makeCubicBezier(x1, y1, x2, y2, orientation)
                y2 += bb.height
                path += ` L ${x2} ${y2} `
            }
            x1 = x2
            y1 = y2
        }
        let [x2, y2] = endPath.split(' ')
        x2 = parseInt(x2)
        y2 = parseInt(y2)
        path += ' '+makeCubicBezier(x1, y1, x2, y2, orientation)
        return(path)
    }

    autoPlace(orientation = 'horizontal', gapx = 80, gapy = 80, tween=1000){

        // Loops create infinite recursion in dfs for getting parents & adjacency lists: Remove them !
        let linksWithoutBackEdges
        if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){
            console.warn('Loop(s) detected... Cannot auto-place !')
            const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links)
            linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0]))
        } else {
            linksWithoutBackEdges = this.flow.links
        }
        const { parents, adj } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges)

        const layers = this.computeLayers(this.flow.nodes, parents)

        // Compute indexes for each layer (int part) & add sub-index for ports
        // Also compute max width/height for each layer
        let maxHeight = 0; let maxWidth = 0
        const layerHeights = []; const layerWidths = []; 
        const indexes = {} // indexes[nid] = { base: <int>, ports: { [portId]: <float in [0,1)> } }
        for(const layer of layers){
            let totHeight = 0; let totWidth = 0
            for(const [idx, nid] of layer.entries()){
                const bb = this.stagedNodes[nid].getBoundingClientRect()
                totHeight += bb.height + gapy
                totWidth += bb.width + gapx
                indexes[nid] = { base: idx, ports: this.computePortOffsets(nid, orientation) }
            }
            if(totHeight>maxHeight) maxHeight = totHeight
            layerHeights.push(totHeight)
            if(totWidth>maxWidth) maxWidth = totWidth
            layerWidths.push(totWidth)
        }
        
        // If any long-links, create placeholders for skipped layers
        this.flow.longLinks = this.findLongLinks(this.flow.links)
        for(const link of this.flow.longLinks){
            for(const layerIdx of link.skippedLayers){ 
                const nid = `longLinkPlaceHolder_${crypto.randomUUID()}`
                layers[layerIdx].push(nid)
                link.interNodes.push(nid)
            }
        }

        // Reorder layers to avoid crossings thanks to indexes
        this.reorderLayers(layers, parents, indexes, orientation)

        // Finally place everything
        if(orientation=='horizontal'){
            let x = gapx
            for(const [idx, layer] of layers.entries()){
                let wMax = this.getMaxWidth(layer)
                let y = ((maxHeight - layerHeights[idx]) / 2) + gapy
                for(const nid of layer){
                    if(!nid.startsWith('longLinkPlaceHolder_')) {
                        const bb = this.stagedNodes[nid].getBoundingClientRect()
                        this.moveNode(nid, x, y, orientation, tween)
                        y +=  gapy + bb.height
                    } else {
                        this.addFakeNode(nid, x, y, wMax, 10)
                        this.moveNode(nid, x, y, orientation, tween)
                        y +=  gapy + 10 //TODO
                    }
                }
                x += wMax + gapx
            }
        } else if(orientation=='vertical'){
            let y = gapy
            for(const [idx, layer] of layers.entries()){
                let hMax = this.getMaxHeight(layer)
                let x = ((maxWidth - layerWidths[idx]) / 2) + gapx
                for(const nid of layer){
                    if(!nid.startsWith('longLinkPlaceHolder_')){
                        const bb = this.stagedNodes[nid].getBoundingClientRect()
                        this.moveNode(nid, x, y, orientation, tween)
                        x +=  gapx + bb.width
                    } else {
                        this.addFakeNode(nid, x, y, 10, hMax)
                        this.moveNode(nid, x, y, orientation, tween)
                        x +=  gapx + 10 //TODO
                    }
                }
                y += hMax + gapy
            }            
        }
    }

    getMaxWidth(layer){
        return(layer.filter(nid => 
            !nid.startsWith('longLinkPlaceHolder_'))
            .map(nid => this.stagedNodes[nid].getBoundingClientRect().width)
            .reduce((a, b) => a > b ? a : b, 0)
        )
    }

    getMaxHeight(layer){
        return(layer.filter(nid => 
            !nid.startsWith('longLinkPlaceHolder_'))
            .map(nid => this.stagedNodes[nid].getBoundingClientRect().height)
            .reduce((a, b) => a > b ? a : b, 0)
        )
    }

    computePortOffsets(nid, orientation = 'horizontal'){
        const node = this.stagedNodes[nid]
        if(!node || !node.ports) return({})
        const axis = (orientation === 'vertical') ? 'x' : 'y'
        const nodeRect = node.getBoundingClientRect()
        const ports = Object.entries(node.ports)
            .map(([pid, p]) => {
                const r = p.el.getBoundingClientRect()
                const pos = (axis === 'x')
                    ? (r.left + (r.width / 2) - nodeRect.left)
                    : (r.top  + (r.height / 2) - nodeRect.top)
                return({ pid, pos })
            })
            .sort((a, b) => a.pos - b.pos) // smaller pos => "higher/left" => smaller offset

        const denom = ports.length + 1
        const offsets = {}
        for(const [rank, item] of ports.entries()){
            offsets[item.pid] = rank / denom // always < 1
        }
        return(offsets)
    }

    reorderLayers(layers, parents, indexes, orientation = 'horizontal'){
        const swap = (vect, todo) => {
            for(const s of todo){
                [vect[s[0]], vect[s[1]]] = [vect[s[1]], vect[s[0]]]
            }
        }
        const adjIndex = (nid, portId) => {
            const info = indexes?.[nid]
            const base = (info && info.base !== undefined) ? info.base : 0
            const off = (portId && info?.ports?.[portId] !== undefined) ? info.ports[portId] : 0
            return(base + off)
        }
        for(const [lidx, layer] of layers.entries()){
            if(lidx==0) continue
            const toSwap = []
            for(let i=0; i<layer.length; i++){
                const nid1 = layer[i]
                if(nid1.startsWith('longLinkPlaceHolder_')) continue
                // some parents can be in far layers, but at least one is in the prev layer (by definition of layer)
                const pnid1 = parents[nid1].find((nid => layers[lidx-1].includes(nid)))
                for(let j=i+1; j<layer.length; j++){
                    const nid2 = layer[j]
                    if(nid2.startsWith('longLinkPlaceHolder_')) continue
                    const pnid2 = parents[nid2].find((nid => layers[lidx-1].includes(nid)))
                    const link1 = (pnid1) ? this.getLink(pnid1, nid1) : null
                    const link2 = (pnid2) ? this.getLink(pnid2, nid2) : null
                    const p1 = adjIndex(pnid1, link1?.from?.[1])
                    const p2 = adjIndex(pnid2, link2?.from?.[1])
                    const c1 = adjIndex(nid1,  link1?.to?.[1])
                    const c2 = adjIndex(nid2,  link2?.to?.[1])
                    if(((p1 - p2) * (c1 - c2)) < 0) { // crossing (now refined by per-port ordering)
                        toSwap.push([i, j])
                    }
                }
            }
            swap(layer, toSwap)
        }
    }

    moveNode(nid, destx, desty, orientation, duration = 200, cb) {
        const t0 = performance.now()
        const bb = this.stagedNodes[nid].getBoundingClientRect()
        const parentbb = this.stagedNodes[nid].parentElement.getBoundingClientRect()
        const x0=bb.x - parentbb.x
        const y0 = bb.y - parentbb.y
        function frame(t) {
            const p = Math.min((t - t0) / duration, 1)
            const k = p * p * (3 - 2 * p) // smoothstep            
            const x = x0 + (destx - x0) * k
            const y = y0 + (desty - y0) * k            
            this.stagedNodes[nid].style.left = `${x}px`
            this.stagedNodes[nid].style.top = `${y}px`
            this.updateWires(nid, orientation)
            
            if(p < 1) requestAnimationFrame(frame.bind(this))
        }    
        requestAnimationFrame(frame.bind(this))
    }

    updateWires(nid, orientation){
        const wires = Object.keys(this.stagedWires)
            .filter(id => (id.startsWith(nid+'_')||id.endsWith('_'+nid)))
            .map(id => this.stagedWires[id])
        for(const wire of wires){
            const [nid1, nid2] = wire.dataset.id.split('_')
            const lnk = this.getLink(nid1, nid2)
            const longLink = this.flow.longLinks.find(item => (item.link.from[0] == lnk.from[0] && item.link.from[1] == lnk.from[1] && item.link.to[0] == lnk.to[0] && item.link.to[1] == lnk.to[1]))
            if(longLink) {
                const path = this.bezierInterNodes(nid1, lnk.from[1], nid2, lnk.to[1], longLink.interNodes, orientation, this.getBZAttribute('tension')) 
                wire.setAttribute('d', path)
            } else {
                const path = this.bezierNodes(nid1, lnk.from[1], nid2, lnk.to[1], this.getBZAttribute('tension'))
                wire.setAttribute('d', path)
            }
        }
    }


    
    getLink(nid1, nid2){
        return(this.flow.links.find(item => ((item.from[0]==nid1) && (item.to[0]==nid2))))
    }

    buildGraphStructures(nodes, links, includeLinkIndexes = false) {
        const parents = {}
        const adj = {}
        nodes.forEach(n => {
            parents[n.id] = []
            adj[n.id] = []
        })

        links.forEach((link, idx) => {
            const from = link.from[0]
            const to   = link.to[0]
            if(link.from[0] !== link.to[0]) { // Skip self-loops
                parents[to].push(from)
                if(includeLinkIndexes) {
                    adj[from].push({ to, linkIdx: idx })
                } else {
                    adj[from].push(to)
                }
            }
        })
        return({ parents, adj })
    }

    computeLayers(nodes, parents) {
        const layer = {}
        const dfs = (id) => {
            if (layer[id] !== undefined) return(layer[id])
            
            if (parents[id].length === 0) {
                layer[id] = 0
            } else {
                layer[id] = 1 + Math.max(...parents[id].map(dfs))
            }
            return(layer[id])
        }        
        nodes.forEach(n => dfs(n.id))
        const t = []
        for(const nid in layer) { 
            if(!t[layer[nid]]) t[layer[nid]]=[]
            t[layer[nid]].push(nid)
        } 
        return(t)
    }

    hasAnyLoop(nodes, links) {
        if(links.some(l => l.from[0] === l.to[0])) return(true) // self-loops
        
        const { adj } = this.buildGraphStructures(nodes, links) 
        const visiting = new Set(); 
        const visited = new Set()
        const dfs = (nid) => {
            if(visiting.has(nid)) {
                return(true)
            }

            if(visited.has(nid)) return(false)
            visiting.add(nid)
            for(const m of adj[nid]) {
                if(dfs(m)) {
                    return(true)
                }
            }
            visiting.delete(nid)
            visited.add(nid)
            return(false)
        }    
        return(nodes.map(n => n.id).some(dfs))
    }
    
    findBackEdges(nodes, links) {
        const { adj } = this.buildGraphStructures(nodes, links, true)
        const color = {}; nodes.forEach(n => color[n.id] = 'white')        
        const backEdges = []
        
        function dfs(u) {
            color[u] = 'gray'
            for (const neighbor of adj[u]) {
                const v = neighbor.to
                const linkIdx = neighbor.linkIdx
                if (color[v] === 'gray') {
                    backEdges.push(linkIdx)   // 👈 cycle edge - return link index
                } else if (color[v] === 'white') {
                    dfs(v)
                }
            }
            color[u] = 'black'
        }
        
        nodes.forEach(n => {
            if (color[n.id] === 'white') dfs(n.id)
        })
        
        return(backEdges)
    }

    findLongLinks(links) {
        let linksWithoutBackEdges
        if(this.hasAnyLoop(this.flow.nodes, this.flow.links)){
            console.warn('Loop(s) detected... Cannot auto-place !')
            const backEdges = this.findBackEdges(this.flow.nodes, this.flow.links)
            linksWithoutBackEdges = this.flow.links.filter((link, idx) => (!backEdges.includes(idx)) && (link.from[0] != link.to[0]))
        } else {
            linksWithoutBackEdges = this.flow.links
        }
        /// Yes that means we ignore long & back links !
        const { parents } = this.buildGraphStructures(this.flow.nodes, linksWithoutBackEdges)
        const layers = this.computeLayers(this.flow.nodes, parents)
        const crossLayerLinks = []
        for(const link of links){
            const from = link.from[0]
            const to = link.to[0]
            const idx1 = layers.findIndex(layer => layer.includes(from))
            const idx2 = layers.findIndex(layer => layer.includes(to))
            if(Math.abs(idx1-idx2)>1) {
                const lowerIdx = idx1<idx2 ? idx1 : idx2
                const higherIdx = idx1>idx2 ? idx1 : idx2
                crossLayerLinks.push({
                    link,
                    linkIdx: link.linkIdx,
                    interNodes: [],
                    skippedLayers: Array.from({ length: higherIdx - lowerIdx - 1 }, (_, i) => lowerIdx + i + 1),
                })
            }
        }
        return(crossLayerLinks)
    }
}

Buildoz.define('graflow', BZgraflow)

