// Layered flow / state-machine diagram (UML-style) for workflows, sagas, and lifecycle schemes.
//
// Unlike force-graph.js (force-directed, for relationship graphs), this lays the graph out in
// deterministic layered RANKS -- states/steps become boxes, transitions become labeled directional
// arrows. Layout is delegated to dagre (vendored in lib/dagre.min.js): it ranks nodes, routes every
// edge -- parallel multi-edges, self-loops, and back-edges all handled -- and places each edge LABEL
// as a real layout box, so labels never collide with a node or with each other. We keep the d3 SVG
// drawing + theming; dagre only computes geometry. Reuses the d3 that force-graph.js already loads.
//
//   FlowDiagram.create(containerId, nodes, edges, options)
//     nodes: [{ id, label, kind, status? }]
//       kind:   'start' | 'step' | 'end' | 'reject' | 'restart' | 'missing'
//       status: (optional, saga step fill) 'pending'|'running'|'completed'|'failed'|'skipped'
//     edges: [{ source, target, label?, kind? }]
//       kind:   'normal' (default) | 'fail' | 'compensate'
//     options: { direction?: 'TB'|'LR' (default 'TB'),
//                onNodeClick?: DotNetObjectReference }  -- when provided, clicking
//                a node calls .invokeMethodAsync('InvokeCallback', nodeId)
//   FlowDiagram.destroy(containerId)
window.FlowDiagram = {
    instances: {},

    NODE_H: 34,

    statusFill: {
        completed: '#10b981', running: '#3b82f6', failed: '#ef4444',
        skipped: '#6b7280', pending: '#30363d',
    },
    edgeColor: { normal: '#8b949e', fail: '#ef4444', compensate: '#f59e0b' },

    create: function (containerId, nodes, edges, options) {
        this.destroy(containerId);
        const container = document.getElementById(containerId);
        if (!container || typeof d3 === 'undefined' || typeof dagre === 'undefined') return;
        container.innerHTML = '';

        const dir = (options && options.direction) || 'TB';
        const onNodeClick = (options && options.onNodeClick) || null;
        const self = this;
        const byId = {};
        nodes.forEach(n => { byId[n.id] = n; });
        // Drop edges with dangling endpoints defensively.
        edges = edges.filter(e => byId[e.source] && byId[e.target]);
        if (!nodes.length) return { nodeCount: 0, edgeCount: 0 };   // empty scheme -> nothing to lay out

        // Geometry per node: state/step boxes sized to their label; small circles for start/end markers.
        const geom = (n) => {
            if (n.kind === 'step' || n.kind === 'missing') {
                const w = Math.max(86, Math.min(230, (n.label || '').length * 6.7 + 28));
                return { w, h: this.NODE_H, r: 0 };
            }
            return { w: 26, h: 26, r: 13 };
        };
        nodes.forEach(n => { n._g = geom(n); });

        // Layout with dagre. We hand it node sizes and per-edge label sizes; it ranks, routes, and
        // places labels with collision avoidance. Read back node centers, edge point-paths, label centers.
        const g = new dagre.graphlib.Graph({ multigraph: true });
        g.setGraph({ rankdir: dir, nodesep: 34, ranksep: 60, edgesep: 14, marginx: 18, marginy: 18 });
        g.setDefaultEdgeLabel(() => ({}));
        nodes.forEach(n => g.setNode(n.id, { width: n._g.w, height: n._g.h }));
        edges.forEach((e, i) => {
            e._name = 'e' + i;   // unique name keeps dagre's parallel edges distinct
            const hasLabel = !!e.label;
            g.setEdge(e.source, e.target, {
                width: hasLabel ? e.label.length * 6 + 12 : 1,
                height: hasLabel ? 18 : 1,
                labelpos: 'c',
            }, e._name);
        });
        dagre.layout(g);

        nodes.forEach(n => { const dn = g.node(n.id); n._x = dn.x; n._y = dn.y; });

        // SVG sized to the dagre graph bounds (margins + self-loops + labels already accounted for).
        const width = g.graph().width || 100;
        const height = g.graph().height || 100;
        const inst = { svg: null };
        this.instances[containerId] = inst;
        const vw = container.clientWidth || width;
        const vh = container.clientHeight || height;
        inst.svg = d3.select(container).append('svg')
            .attr('width', '100%').attr('height', '100%')
            .attr('viewBox', [0, 0, Math.max(width, vw), Math.max(height, vh)]);

        const defs = inst.svg.append('defs');
        Object.entries(this.edgeColor).forEach(([k, c]) => {
            defs.append('marker').attr('id', `fd-arrow-${containerId}-${k}`)
                .attr('viewBox', '0 -5 10 10').attr('refX', 8).attr('refY', 0)
                .attr('markerWidth', 7).attr('markerHeight', 7).attr('orient', 'auto')
                .append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', c);
        });

        const root = inst.svg.append('g');

        // Edges: draw each dagre-routed polyline as a smooth curve through its points. curveCatmullRom
        // passes THROUGH every point (incl. endpoints) so the arrowhead lands on the target border and
        // self-loops trace their routed points cleanly.
        const lineGen = d3.line().x(d => d.x).y(d => d.y).curve(d3.curveCatmullRom.alpha(0.5));
        edges.forEach(e => {
            const de = g.edge(e.source, e.target, e._name);
            if (!de || !de.points || !de.points.length) return;
            const kind = e.kind || 'normal';
            const col = self.edgeColor[kind] || self.edgeColor.normal;
            root.append('path').attr('d', lineGen(de.points)).attr('fill', 'none')
                .attr('stroke', col).attr('stroke-width', 1.6)
                .attr('stroke-dasharray', kind === 'compensate' ? '5,4' : null)
                .attr('marker-end', `url(#fd-arrow-${containerId}-${kind})`);

            if (e.label && de.x != null && de.y != null) {
                const lw = e.label.length * 6 + 10;
                const lg = root.append('g').attr('transform', `translate(${de.x},${de.y})`);
                lg.append('rect').attr('x', -lw / 2).attr('y', -9).attr('width', lw).attr('height', 16)
                    .attr('rx', 8).attr('fill', 'var(--surface, #0d1117)').attr('stroke', col).attr('stroke-opacity', 0.5);
                lg.append('text').attr('text-anchor', 'middle').attr('y', 3)
                    .attr('font-size', '10px').attr('fill', col).text(e.label);
            }
        });

        // Nodes.
        const node = root.append('g').selectAll('g').data(nodes).join('g')
            .attr('transform', d => `translate(${d._x},${d._y})`);

        node.each(function (d) {
            const g2 = d3.select(this);
            if (d.kind === 'step' || d.kind === 'missing') {
                // Non-status nodes (e.g. lifecycle states) must use a THEME surface, not a hardcoded dark
                // fill -- otherwise in light theme the box stays dark while the label uses var(--text) (dark),
                // rendering the text invisible (dark-on-dark). var(--surface) contrasts with var(--text) in
                // both themes AND with the var(--surface-2) canvas.
                const fill = d.status ? (self.statusFill[d.status] || '#161b22') : 'var(--surface, #161b22)';
                const stroke = d.status ? fill : 'var(--border-color, #30363d)';
                g2.append('rect')
                    .attr('x', -d._g.w / 2).attr('y', -d._g.h / 2)
                    .attr('width', d._g.w).attr('height', d._g.h).attr('rx', 7)
                    .attr('fill', fill).attr('fill-opacity', d.status ? 0.9 : 1)
                    .attr('stroke', stroke).attr('stroke-width', 1.4)
                    .attr('stroke-dasharray', d.kind === 'missing' ? '4,3' : null);
                g2.append('text').attr('text-anchor', 'middle').attr('y', 4)
                    .attr('font-size', '11px')
                    .attr('fill', d.status ? '#fff' : 'var(--text, #e6edf3)')
                    .text(self.truncate(d.label || d.id, Math.floor((d._g.w - 16) / 6.5)));
            } else {
                const c = { start: '#ec4899', end: '#10b981', reject: '#ef4444', restart: '#f59e0b' }[d.kind] || '#6366f1';
                g2.append('circle').attr('r', 11).attr('fill', d.kind === 'start' ? c : 'var(--surface,#0d1117)')
                    .attr('stroke', c).attr('stroke-width', 2.2);
                if (d.kind === 'end' || d.kind === 'reject') g2.append('circle').attr('r', 5).attr('fill', c); // UML final
                g2.append('text').attr('text-anchor', 'middle').attr('y', 24)
                    .attr('font-size', '10px').attr('fill', 'var(--text-2, #8b949e)')
                    .text(d.label || '');
            }
            g2.append('title').text(d.label || d.id);
        });

        if (onNodeClick) {
            node.style('cursor', 'pointer')
                .on('click', function (event, d) {
                    event.stopPropagation();
                    onNodeClick.invokeMethodAsync('InvokeCallback', d.id);
                });
        }

        // Zoom / pan. dagre coordinates already start at the graph margin, so no origin translate needed.
        const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', (ev) => root.attr('transform', ev.transform));
        inst.svg.call(zoom);

        return { nodeCount: nodes.length, edgeCount: edges.length };
    },

    truncate: function (text, maxLen) {
        if (!text) return '';
        return text.length > maxLen ? text.substring(0, Math.max(1, maxLen - 1)) + '…' : text;
    },

    destroy: function (containerId) {
        delete this.instances[containerId];
        const c = document.getElementById(containerId);
        if (c) c.innerHTML = '';
    },
};
