// Copyright (c) 2026 Modelware. All rights reserved.

import { CanvasMarkdownBlockRenderer, displayLabelFromIri } from './renderer.js';
import type { MdBlockExecutionResult } from './types.js';
import { isIriValue, shortLabelFromIri } from './wikilink-utils.js';

// --- CSS variable constants for base styles (auto-update on VS Code theme) ---
const CSS_NODE_FILL    = 'var(--vscode-button-secondaryBackground, var(--vscode-button-background, #5a5a8a))';
const CSS_NODE_STROKE  = 'var(--vscode-button-border, var(--vscode-editorWidget-border, #6b7280))';
const CSS_NODE_TEXT    = 'var(--vscode-editor-foreground, var(--oml-static-foreground, #24292f))';
const CSS_LIT_FILL     = 'var(--vscode-badge-background, #5a5a8a)';
const CSS_LIT_STROKE   = 'var(--vscode-editorWidget-border, #6b7280)';
const CSS_EDGE_STROKE  = 'var(--vscode-editorWidget-border, #6b7280)';
const CSS_EDGE_TEXT    = CSS_EDGE_STROKE;
const CSS_EDGE_BG      = 'var(--vscode-editor-background, var(--oml-static-background, #ffffff))';
const CSS_FONT_FAMILY  = 'var(--vscode-editor-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)';

/** Extra height beneath the circle/rect shape to accommodate the below-node label. */
const NODE_LABEL_HEIGHT = 18;
const NODE_FONT_SIZE = 10;
const EDGE_FONT_SIZE = 10;

// --- Module-level library caches ---
let x6GraphCtor: (new (options: unknown) => any) | undefined;
let dagreLib: any;

// --- Types ---

type GraphThemePalette = {
    nodeFill: string;
    nodeStroke: string;
    nodeText: string;
    literalFill: string;
    literalStroke: string;
    literalText: string;
    edgeStroke: string;
    edgeText: string;
    edgeLabelBg: string;
    edgeLabelText: string;
    selectedStroke: string;
    fontFamily: string;
    foreground: string;
    background: string;
    muted: string;
    link: string;
    accent: string;
    accentForeground: string;
    border: string;
};

type NodeData = {
    id: string;
    label: string;
    value: string;
    degree: number;
    literal: boolean;
    group: string;
    hidden?: boolean;
};

type EdgeData = {
    id: string;
    source: string;
    target: string;
    value: string;
    label: string;
    hidden?: boolean;
};

type ForceLayoutOptions = {
    repulsion: number;
    linkDistance: number;
    springStrength: number;
    gravity: number;
    damping: number;
    maxSpeed: number;
    intraGroupAttraction: number;
    interGroupRepulsionFactor: number;
    axisPreference: 'horizontal' | 'vertical' | 'none';
    axisStrength: number;
};

type ResolvedLayout = {
    mode: 'force' | 'radial' | 'dag' | 'grid';
    running: boolean;
    fit: boolean;
    padding: number;
    force: ForceLayoutOptions;
    radial: {
        startAngle: number;
        clockwise: boolean;
        sortBy: 'degree' | 'label';
    };
    dag: {
        rankDir: 'TB' | 'LR';
        nodeSep: number;
        rankSep: number;
        acyclic: boolean;
    };
    grid: {
        rows?: number;
        cols?: number;
        sortBy: 'degree' | 'label';
        condense: boolean;
    };
};

type GraphStyleRule = {
    kind: 'node' | 'edge';
    condition?: string;
    style: Record<string, string>;
    group?: string;
};

type SelectorNodeContext = {
    id: string;
    label: string;
    value: string;
    type: string[];
    degree: number;
    literal: boolean;
    group: string;
};

type SelectorEdgeContext = {
    value: string;
    source: SelectorNodeContext;
    target: SelectorNodeContext;
};

type NodeContext = {
    value: string;
    incoming: SelectorEdgeContext[];
    outgoing: SelectorEdgeContext[];
    neighbors: SelectorNodeContext[];
};

type EdgeContext = {
    value: string;
    source: SelectorNodeContext;
    target: SelectorNodeContext;
};

type CsvDataset = {
    columns: string[];
    rows: string[][];
    downloadCsv: (content: string) => void;
};

type GraphExpandPayload = {
    columns: string[];
    rows: string[][];
};

type GraphMutableState = {
    nodesById: Map<string, NodeData>;
    edgeKeys: Set<string>;
    edgeSequence: number;
    minDegree: number;
    maxDegree: number;
};

const RDF_TYPE_IRI = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';

// --- Renderer ---

export class GraphMarkdownBlockRenderer extends CanvasMarkdownBlockRenderer {
    protected readonly canvasKinds = ['graph'] as const;

    render(result: MdBlockExecutionResult): HTMLElement {
        const container = this.createResultContainer(result.status);
        const payload = result.payload;
        if (!payload) {
            container.appendChild(this.createMessageContainer('Graph renderer requires tabular payload.'));
            return container;
        }

        const subjectIndex = payload.columns.indexOf('subject');
        const predicateIndex = payload.columns.indexOf('predicate');
        const objectIndex = payload.columns.indexOf('object');
        if (subjectIndex < 0 || predicateIndex < 0 || objectIndex < 0) {
            container.appendChild(this.createMessageContainer(
                "Graph renderer requires columns named 'subject', 'predicate', and 'object'."
            ));
            return container;
        }

        const graphRoot = document.createElement('div');
        graphRoot.className = 'graph-canvas-root';
        graphRoot.style.height = resolveCanvasHeight(result.options);
        graphRoot.style.minHeight = resolveCanvasMinHeight(result.options);
        container.appendChild(graphRoot);

        const nodeMap = new Map<string, NodeData>();
        const edgeList: EdgeData[] = [];
        const seenEdges = new Set<string>();

        for (const row of payload.rows) {
            const subject = row[subjectIndex] ?? '';
            const predicate = row[predicateIndex] ?? '';
            const object = row[objectIndex] ?? '';
            if (!subject || !predicate || !object) {
                continue;
            }

            ensureNode(nodeMap, subject);
            ensureNode(nodeMap, object);

            const source = toNodeId(subject);
            const target = toNodeId(object);
            const edgeKey = `${source}|${toNodeId(predicate)}|${target}`;
            if (seenEdges.has(edgeKey)) {
                continue;
            }
            seenEdges.add(edgeKey);

            nodeMap.get(source)!.degree += 1;
            nodeMap.get(target)!.degree += 1;

            edgeList.push({
                id: `${edgeKey}|${edgeList.length}`,
                source,
                target,
                value: predicate,
                label: shortLabel(predicate),
            });
        }

        if (nodeMap.size === 0) {
            container.appendChild(this.createMessageContainer('No graph data returned.'));
            return container;
        }

        const degrees = [...nodeMap.values()].map((n) => n.degree);
        const minDegree = Math.min(...degrees);
        const maxDegree = Math.max(...degrees);
        const layout = resolveLayoutOptions(result.options);
        const stylesheet = compileGraphStylesheet(result.options);
        const expandOnClick = resolveExpandOnClickOption(result.options);
        const nodeDataList = [...nodeMap.values()];
        const csvDataset: CsvDataset = {
            columns: payload.columns.slice(),
            rows: payload.rows.map((row) => row.slice()),
            downloadCsv: (content: string) => this.requestTextFileDownload(content, 'graph', 'csv'),
        };

        initializeGraphWhenReady(
            graphRoot,
            nodeDataList,
            edgeList,
            minDegree,
            maxDegree,
            container,
            layout,
            stylesheet,
            result.blockSource,
            result.blockId,
            expandOnClick,
            csvDataset,
        );

        return container;
    }
}

// --- Graph initialization ---

function initializeGraphWhenReady(
    graphRoot: HTMLElement,
    nodeDataList: NodeData[],
    edgeDataList: EdgeData[],
    minDegree: number,
    maxDegree: number,
    messageContainer: HTMLElement,
    layoutOptions: ResolvedLayout,
    stylesheet: ReadonlyArray<GraphStyleRule>,
    blockSource: string | undefined,
    blockId: string,
    expandOnClick: boolean,
    csvDataset: CsvDataset,
): void {
    const maxAttempts = 20;
    let attempts = 0;

    const tryInit = (): void => {
        attempts += 1;
        if (!graphRoot.isConnected || graphRoot.clientWidth === 0 || graphRoot.clientHeight === 0) {
            if (attempts < maxAttempts) {
                requestAnimationFrame(tryInit);
            }
            return;
        }
        void doGraphInit(
            graphRoot,
            nodeDataList,
            edgeDataList,
            minDegree,
            maxDegree,
            layoutOptions,
            stylesheet,
            blockSource,
            blockId,
            expandOnClick,
            csvDataset,
        ).catch((error) => {
            const detail = error instanceof Error ? error.message : String(error);
            const message = document.createElement('div');
            message.className = 'oml-md-result-message';
            message.textContent = `Graph rendering failed: ${detail}`;
            messageContainer.appendChild(message);
        });
    };

    requestAnimationFrame(tryInit);
}

async function doGraphInit(
    graphRoot: HTMLElement,
    nodeDataList: NodeData[],
    edgeDataList: EdgeData[],
    minDegree: number,
    maxDegree: number,
    layoutOptions: ResolvedLayout,
    stylesheet: ReadonlyArray<GraphStyleRule>,
    blockSource: string | undefined,
    blockId: string,
    expandOnClick: boolean,
    csvDataset: CsvDataset,
): Promise<void> {
    const GraphCtor = await loadX6GraphCtor();
    const graphView: any = new GraphCtor({
        container: graphRoot,
        autoResize: false,
        grid: false,
        panning: { enabled: true },
        mousewheel: {
            enabled: true,
            minScale: 0.3,
            maxScale: 3,
            factor: 1.1,
            modifiers: ['meta', 'ctrl'],
        },
        connecting: {
            allowBlank: false,
            allowNode: false,
            allowPort: false,
            allowEdge: false,
            allowLoop: false,
        },
        interacting: {
            nodeMovable: true,
            edgeMovable: false,
            vertexMovable: false,
            arrowheadMovable: false,
            labelMovable: false,
        },
        background: { color: 'var(--vscode-editor-background)' },
    });

    // Track which nodes are currently being dragged (force engine skips them).
    const grabbedNodes = new Set<string>();
    graphView.on('node:mousedown', ({ node }: { node: any }) => {
        grabbedNodes.add(String(node.id));
    });
    const onDocMouseUp = (): void => {
        grabbedNodes.clear();
    };
    document.addEventListener('mouseup', onDocMouseUp, { passive: true });
    graphRoot.addEventListener('DOMNodeRemovedFromDocument', () => {
        document.removeEventListener('mouseup', onDocMouseUp);
    }, { once: true });

    // Add all nodes and edges.
    for (const data of nodeDataList) {
        const size = computeNodeSize(data.degree, minDegree, maxDegree);
        graphView.addNode(buildNodeDef(data, size));
    }
    for (const data of edgeDataList) {
        graphView.addEdge(buildEdgeDef(data));
    }
    const mutableState: GraphMutableState = {
        nodesById: new Map(nodeDataList.map((node) => [node.id, node])),
        edgeKeys: new Set(edgeDataList.map((edge) => `${edge.source}|${toNodeId(edge.value)}|${edge.target}`)),
        edgeSequence: edgeDataList.length,
        minDegree,
        maxDegree,
    };

    // Resize the X6 canvas whenever the host element resizes.
    const recenterAfterResizeOrMove = (): void => {
        centerGraphContent(graphView, layoutOptions.padding);
    };

    const resizeObserver = new ResizeObserver(() => {
        if (!graphRoot.isConnected) {
            return;
        }
        graphView.resize(graphRoot.clientWidth, graphRoot.clientHeight);
        recenterAfterResizeOrMove();
    });
    resizeObserver.observe(graphRoot);
    // Also watch the result container so that when the page grows wider the
    // graphRoot width (and therefore the X6 SVG) is updated to match.
    const resultContainer = graphRoot.closest('.oml-md-result');
    let resultResizeObserver: ResizeObserver | undefined;
    if (resultContainer instanceof HTMLElement) {
        resultResizeObserver = new ResizeObserver(() => {
            const nextWidth = Math.max(0, Math.floor(resultContainer.clientWidth));
            graphRoot.style.width = `${nextWidth}px`;
            graphView.resize(graphRoot.clientWidth, graphRoot.clientHeight);
            recenterAfterResizeOrMove();
        });
        resultResizeObserver.observe(resultContainer);
    }
    graphRoot.addEventListener('DOMNodeRemovedFromDocument', () => {
        resizeObserver.disconnect();
        resultResizeObserver?.disconnect();
    }, { once: true });

    // Apply user stylesheet rules with resolved theme.* tokens.
    // Base colors use CSS variables and auto-update; only theme.* token resolutions
    // need explicit re-application when the VS Code theme changes.
    const applyCurrentTheme = (): void => {
        if (!graphRoot.isConnected) {
            return;
        }
        const theme = resolveThemePalette();
        // Reset to CSS-variable base attrs so stale user-rule colors are cleared.
        for (const node of graphView.getNodes() as any[]) {
            const nodeData: NodeData = node.getData() as NodeData;
            node.setData({ hidden: false }, { merge: true });
            node.setAttrs(buildNodeBaseAttrs(nodeData.literal));
        }
        for (const edge of graphView.getEdges() as any[]) {
            edge.setData({ hidden: false }, { merge: true });
            edge.setAttrs(buildEdgeBaseAttrs());
            resetEdgeLabelColors(edge);
        }
        applyGraphStylesheet(graphView, stylesheet, theme);
        applyGraphVisibilityState(graphView);
    };

    applyCurrentTheme();

    installGraphNodeInteractions(
        graphView,
        graphRoot,
        applyCurrentTheme,
        blockSource,
        blockId,
        expandOnClick,
    );

    const themeObserver = new MutationObserver(() => {
        applyCurrentTheme();
    });
    themeObserver.observe(document.body, {
        attributes: true,
        attributeFilter: ['class', 'style', 'data-vscode-theme-kind'],
    });
    themeObserver.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ['class', 'style', 'data-vscode-theme-kind'],
    });
    const colorScheme = typeof window.matchMedia === 'function'
        ? window.matchMedia('(prefers-color-scheme: dark)')
        : undefined;
    const onColorSchemeChange = (): void => applyCurrentTheme();
    colorScheme?.addEventListener('change', onColorSchemeChange);
    graphRoot.addEventListener('DOMNodeRemovedFromDocument', () => {
        themeObserver.disconnect();
        colorScheme?.removeEventListener('change', onColorSchemeChange);
    }, { once: true });

    // Position all nodes using the chosen layout algorithm.
    await runLayout(graphView, layoutOptions);
    if (layoutOptions.mode === 'force' && layoutOptions.running) {
        startContinuousForceEngine(graphView, graphRoot, layoutOptions.force, grabbedNodes);
    }

    const onExpandResult = (event: Event): void => {
        if (!(event instanceof CustomEvent)) {
            return;
        }
        const detail = event.detail as { blockId?: unknown; payload?: unknown } | undefined;
        const targetBlockId = typeof detail?.blockId === 'string' ? detail.blockId.trim() : '';
        if (targetBlockId !== blockId) {
            return;
        }
        const payload = normalizeGraphExpandPayload(detail?.payload);
        if (!payload) {
            return;
        }
        mergeExpandedGraphData(graphView, mutableState, payload);
        applyCurrentTheme();
        void runLayout(graphView, layoutOptions).then(() => {
            centerGraphContent(graphView, layoutOptions.padding);
        });
    };
    graphRoot.addEventListener('md-graph-expand-result', onExpandResult);

    installGraphToolbar(graphRoot, graphView, csvDataset);

    // Manual canvas resize handle.
    const parsedMinHeight = parseInt(graphRoot.style.minHeight, 10);
    const canvasMinHeight = Number.isFinite(parsedMinHeight) ? Math.max(120, parsedMinHeight) : 220;
    const resizeHandle = document.createElement('div');
    resizeHandle.className = 'canvas-resize-handle';
    graphRoot.appendChild(resizeHandle);
    let canvasResize: { pointerId: number; startY: number; startHeight: number } | undefined;
    const onResizePointerDown = (event: PointerEvent): void => {
        event.preventDefault();
        event.stopPropagation();
        if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
        canvasResize = { pointerId: event.pointerId, startY: event.clientY, startHeight: graphRoot.clientHeight };
        resizeHandle.setPointerCapture(event.pointerId);
    };
    const onResizePointerMove = (event: PointerEvent): void => {
        if (!canvasResize || event.pointerId !== canvasResize.pointerId) return;
        const delta = event.clientY - canvasResize.startY;
        const newHeight = Math.max(canvasMinHeight, canvasResize.startHeight + delta);
        graphRoot.style.height = `${Math.ceil(newHeight)}px`;
    };
    const onResizePointerEnd = (event: PointerEvent): void => {
        if (!canvasResize || event.pointerId !== canvasResize.pointerId) return;
        canvasResize = undefined;
        recenterAfterResizeOrMove();
    };
    resizeHandle.addEventListener('pointerdown', onResizePointerDown);
    resizeHandle.addEventListener('pointermove', onResizePointerMove);
    resizeHandle.addEventListener('pointerup', onResizePointerEnd);
    resizeHandle.addEventListener('pointercancel', onResizePointerEnd);
    graphView.on('node:mouseup', recenterAfterResizeOrMove);
    graphRoot.addEventListener('DOMNodeRemovedFromDocument', () => {
        graphRoot.removeEventListener('md-graph-expand-result', onExpandResult);
    }, { once: true });
}

function installGraphNodeInteractions(
    graphView: any,
    graphRoot: HTMLElement,
    reapplyBaseTheme: () => void,
    blockSource: string | undefined,
    blockId: string,
    expandOnClick: boolean,
): void {
    const ensureSvgNativeTitle = (element: SVGElement, iri: string): void => {
        let titleNode: SVGTitleElement | null = null;
        for (const child of Array.from(element.children)) {
            if (child instanceof SVGTitleElement) {
                titleNode = child;
                break;
            }
        }
        if (!titleNode) {
            titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
            element.insertBefore(titleNode, element.firstChild);
        }
        titleNode.textContent = iri;
    };

    const applyNativeTooltipTitle = (container: Element | undefined, iri: string, eventTarget?: EventTarget | null): void => {
        if (!iri) {
            return;
        }
        if (container instanceof HTMLElement || container instanceof SVGElement) {
            container.setAttribute('title', iri);
        }
        if (container instanceof Element) {
            for (const element of Array.from(container.querySelectorAll<HTMLElement | SVGElement>('*'))) {
                element.setAttribute('title', iri);
                if (element instanceof SVGElement) {
                    ensureSvgNativeTitle(element, iri);
                }
            }
            if (container instanceof SVGElement) {
                ensureSvgNativeTitle(container, iri);
            }
        }
        if (eventTarget instanceof HTMLElement || eventTarget instanceof SVGElement) {
            eventTarget.setAttribute('title', iri);
            if (eventTarget instanceof SVGElement) {
                ensureSvgNativeTitle(eventTarget, iri);
            }
        }
    };

    let hoveredNodeId: string | undefined;
    const isExpandModifierPressed = (event: MouseEvent | undefined): boolean => Boolean(event?.shiftKey);

    const applyHoverState = (node: any): void => {
        const hoveredId = String(node?.id ?? '');
        if (!hoveredId) {
            return;
        }
        reapplyBaseTheme();
        hoveredNodeId = hoveredId;

        const theme = resolveThemePalette();
        const activeNodeIds = new Set<string>([hoveredId]);
        const activeEdgeIds = new Set<string>();
        const connectedEdges = (graphView.getConnectedEdges(node) as any[]) ?? [];
        for (const edge of connectedEdges) {
            activeEdgeIds.add(String(edge.id));
            const sourceId = String(edge.getSourceCellId?.() ?? '');
            const targetId = String(edge.getTargetCellId?.() ?? '');
            if (sourceId) {
                activeNodeIds.add(sourceId);
            }
            if (targetId) {
                activeNodeIds.add(targetId);
            }
        }

        for (const candidate of graphView.getNodes() as any[]) {
            const candidateId = String(candidate.id);
            const active = activeNodeIds.has(candidateId);
            if (!active) {
                candidate.setAttrs({
                    body: { opacity: 0.2 },
                    label: { opacity: 0.28 },
                });
                continue;
            }
            if (candidateId === hoveredId) {
                candidate.setAttrs({
                    body: {
                        fill: theme.accent,
                        stroke: theme.selectedStroke,
                        strokeWidth: 2,
                        opacity: 1,
                    },
                    label: {
                        fill: theme.accentForeground,
                        opacity: 1,
                    },
                });
            }
        }

        for (const edge of graphView.getEdges() as any[]) {
            const active = activeEdgeIds.has(String(edge.id));
            if (!active) {
                edge.setAttrs({
                    line: { opacity: 0.16 },
                });
                try {
                    edge.prop('labels/0/attrs/label/opacity', 0.2);
                    edge.prop('labels/0/attrs/body/opacity', 0.12);
                } catch {
                    // ignore
                }
                continue;
            }
            edge.setAttrs({
                line: {
                    opacity: 1,
                    stroke: theme.accent,
                    strokeWidth: Math.max(1.6, Number(edge.attr?.('line/strokeWidth')) || 1.6),
                    targetMarker: {
                        name: 'classic',
                        size: 6,
                        fill: theme.accent,
                        stroke: theme.accent,
                    },
                },
            });
            try {
                edge.prop('labels/0/attrs/label/fill', theme.accent);
                edge.prop('labels/0/attrs/body/opacity', 1);
            } catch {
                // ignore
            }
        }
    };

    const clearHoverState = (): void => {
        if (!hoveredNodeId) {
            graphRoot.dispatchEvent(new CustomEvent('md-hide-iri-hover', { bubbles: true }));
            return;
        }
        hoveredNodeId = undefined;
        reapplyBaseTheme();
        graphRoot.dispatchEvent(new CustomEvent('md-hide-iri-hover', { bubbles: true }));
    };

    graphView.on('node:mouseenter', ({ node, e }: { node: any; e?: MouseEvent }) => {
        const data = node?.getData?.() as NodeData | undefined;
        const iri = String(data?.value ?? '');
        applyHoverState(node);
        if (iri) {
            const view = graphView.findViewByCell?.(node);
            const bbox = view?.container?.getBoundingClientRect?.();
            applyNativeTooltipTitle(view?.container as Element | undefined, iri, e?.target ?? null);
            if (bbox) {
                graphRoot.dispatchEvent(new CustomEvent('md-show-iri-hover', {
                    bubbles: true,
                    detail: {
                        iri,
                        previewEnabled: !!e && e.altKey,
                        anchorRect: {
                            left: bbox.left,
                            right: bbox.right,
                            top: bbox.top,
                            bottom: bbox.bottom,
                            width: bbox.width,
                            height: bbox.height,
                        },
                    },
                }));
            }
        }
    });

    graphView.on('node:mouseleave', () => {
        clearHoverState();
    });

    graphView.on('blank:mousemove', () => {
        clearHoverState();
    });

    graphView.on('node:click', ({ node, e }: { node: any; e?: MouseEvent }) => {
        if (e && e.button !== 0) {
            return;
        }
        const data = node?.getData?.() as NodeData | undefined;
        const iri = String(data?.value ?? '');
        if (!isIriValue(iri)) {
            return;
        }
        if (expandOnClick && isExpandModifierPressed(e)) {
            const source = (blockSource ?? '').trim();
            if (!source) {
                return;
            }
            graphRoot.dispatchEvent(new CustomEvent('md-graph-expand-request', {
                bubbles: true,
                detail: {
                    blockId,
                    blockSource: source,
                    contextMemberIri: iri.trim(),
                },
            }));
            return;
        }
        graphRoot.dispatchEvent(new CustomEvent('md-navigate-iri', {
            bubbles: true,
            detail: { iri },
        }));
    });
}

// --- X6 / dagre library loaders ---

async function loadX6GraphCtor(): Promise<new (options: unknown) => any> {
    if (x6GraphCtor) {
        return x6GraphCtor;
    }
    const mod = await import('@antv/x6');
    const ctor = (mod as any).Graph;
    if (typeof ctor !== 'function') {
        throw new Error('X6 Graph constructor is unavailable in @antv/x6');
    }
    x6GraphCtor = ctor as new (options: unknown) => any;
    return x6GraphCtor;
}

async function loadDagreLib(): Promise<any> {
    if (dagreLib) {
        return dagreLib;
    }
    const mod = await import('@dagrejs/dagre');
    dagreLib = (mod as any).default ?? mod;
    return dagreLib;
}

// --- Node / edge definition builders ---

function computeNodeSize(degree: number, minDegree: number, maxDegree: number): number {
    if (minDegree === maxDegree) {
        return 28;
    }
    // Keep default nodes compact while still preserving degree-based variation.
    const t = Math.max(0, Math.min(1, degree / 10));
    return Math.round(16 + t * (40 - 16));
}

/** Base attrs for a node using CSS variables so color auto-updates with VS Code theme. */
function buildNodeBaseAttrs(literal: boolean): Record<string, unknown> {
    return {
        body: {
            fill: literal ? CSS_LIT_FILL : CSS_NODE_FILL,
            stroke: literal ? CSS_LIT_STROKE : CSS_NODE_STROKE,
            strokeWidth: 1,
            opacity: 1,
        },
        label: {
            fill: CSS_NODE_TEXT,
            fontSize: NODE_FONT_SIZE,
            fontFamily: CSS_FONT_FAMILY,
            opacity: 1,
        },
    };
}

/** Base attrs for an edge using CSS variables. */
function buildEdgeBaseAttrs(): Record<string, unknown> {
    return {
        line: {
            stroke: CSS_EDGE_STROKE,
            strokeWidth: 1.4,
            opacity: 1,
            targetMarker: {
                name: 'classic',
                size: 6,
                fill: CSS_EDGE_STROKE,
                stroke: CSS_EDGE_STROKE,
            },
        },
    };
}

/** Resets edge label colors to CSS variable defaults (called on theme change). */
function resetEdgeLabelColors(edge: any): void {
    try {
        edge.prop('labels/0/attrs/label/fill', CSS_EDGE_TEXT);
        edge.prop('labels/0/attrs/label/opacity', 1);
        edge.prop('labels/0/attrs/body/fill', CSS_EDGE_BG);
        edge.prop('labels/0/attrs/body/opacity', 1);
    } catch {
        // ignore - edge may have no labels
    }
}

function buildNodeDef(data: NodeData, size: number): Record<string, unknown> {
    const isLiteral = data.literal;
    const tagName = isLiteral ? 'rect' : 'circle';
    const bodyAttrs: Record<string, unknown> = isLiteral
        ? { width: size, height: size, rx: 4, ry: 4,
            fill: CSS_LIT_FILL, stroke: CSS_LIT_STROKE, strokeWidth: 1 }
        : { r: size / 2, cx: size / 2, cy: size / 2,
            fill: CSS_NODE_FILL, stroke: CSS_NODE_STROKE, strokeWidth: 1 };

    return {
        id: data.id,
        markup: [
            { tagName, selector: 'body' },
            { tagName: 'text', selector: 'label' },
        ],
        x: 0,
        y: 0,
        width: size,
        height: size + NODE_LABEL_HEIGHT,   // extra room below shape for label
        attrs: {
            body: bodyAttrs,
            label: {
                text: data.label,
                fill: CSS_NODE_TEXT,
                fontSize: NODE_FONT_SIZE,
                fontFamily: CSS_FONT_FAMILY,
                textAnchor: 'middle',
                textVerticalAnchor: 'top',
                refX: '50%',
                refY: size + 3,             // below the visible shape
            },
        },
        data: {
            label: data.label,
            value: data.value,
            degree: data.degree,
            literal: data.literal,
            group: '',
            hidden: Boolean(data.hidden),
        },
        zIndex: 1,
    };
}

function buildEdgeDef(data: EdgeData): Record<string, unknown> {
    return {
        id: data.id,
        source: { cell: data.source },
        target: { cell: data.target },
        router: { name: 'normal' },
        connector: { name: 'rounded', args: { radius: 6 } },
        attrs: {
            line: {
                stroke: CSS_EDGE_STROKE,
                strokeWidth: 1.4,
                targetMarker: {
                    name: 'classic',
                    size: 6,
                    fill: CSS_EDGE_STROKE,
                    stroke: CSS_EDGE_STROKE,
                },
            },
        },
        labels: [
            {
                position: 0.5,
                markup: [
                    { tagName: 'rect', selector: 'body' },
                    { tagName: 'text', selector: 'label' },
                ],
                attrs: {
                    body: {
                        ref: 'label',
                        refX: -2,
                        refY: -2,
                        refWidth: '100%',
                        refHeight: '100%',
                        refWidth2: 4,
                        refHeight2: 4,
                        fill: CSS_EDGE_BG,
                        rx: 2,
                        ry: 2,
                        stroke: 'none',
                    },
                    label: {
                        text: data.label,
                        fill: CSS_EDGE_TEXT,
                        fontSize: EDGE_FONT_SIZE,
                        fontFamily: CSS_FONT_FAMILY,
                        textAnchor: 'middle',
                        textVerticalAnchor: 'middle',
                    },
                },
            },
        ],
        data: {
            label: data.label,
            value: data.value,
            hidden: Boolean(data.hidden),
        },
        zIndex: 0,
    };
}

// --- Layout ---

async function runLayout(graphView: any, options: ResolvedLayout): Promise<void> {
    const nodes = graphView.getNodes() as any[];
    if (nodes.length === 0) {
        return;
    }

    if (options.mode === 'force') {
        // Place nodes in a circle; the continuous force engine will handle physics.
        // Radius is sized so the initial circle fits comfortably at 1:1 scale —
        // node spacing ~60 px (≈1.5× default node diameter) → r ≈ N × 10 px.
        const r = Math.max(50, nodes.length * 10);
        nodes.forEach((node: any, i: number) => {
            const angle = (2 * Math.PI * i) / nodes.length;
            node.setPosition(Math.round(r * Math.cos(angle)), Math.round(r * Math.sin(angle)));
        });
        if (options.fit) {
            fitGraphContent(graphView, options.padding);
        }
        return;
    }

    if (options.mode === 'radial') {
        const { startAngle, clockwise, sortBy } = options.radial;
        const sorted = [...nodes].sort((a: any, b: any) => {
            if (sortBy === 'label') {
                return String((a.getData() as NodeData)?.label ?? '').localeCompare(String((b.getData() as NodeData)?.label ?? ''));
            }
            return Number((b.getData() as NodeData)?.degree ?? 0) - Number((a.getData() as NodeData)?.degree ?? 0);
        });
        const ringSpacing = 80;
        const nodeSpacing = 55;
        const rings: any[][] = [];
        const remaining = [...sorted];
        let ringIndex = 0;
        while (remaining.length > 0) {
            const capacity = ringIndex === 0
                ? 1
                : Math.max(6, Math.floor((2 * Math.PI * (ringIndex + 1) * ringSpacing) / nodeSpacing));
            rings.push(remaining.splice(0, capacity));
            ringIndex += 1;
        }
        for (let r = 0; r < rings.length; r += 1) {
            const ring = rings[r];
            const radius = (r + 1) * ringSpacing;
            ring.forEach((node: any, i: number) => {
                const angle = startAngle + (clockwise ? 1 : -1) * (2 * Math.PI * i) / ring.length;
                node.setPosition(Math.round(radius * Math.cos(angle)), Math.round(radius * Math.sin(angle)));
            });
        }
        if (options.fit) {
            fitGraphContent(graphView, options.padding);
        }
        return;
    }

    if (options.mode === 'dag') {
        const dagre = await loadDagreLib();
        const g = new dagre.graphlib.Graph({ multigraph: true });
        g.setGraph({
            rankdir: options.dag.rankDir,
            nodesep: options.dag.nodeSep,
            ranksep: options.dag.rankSep,
        });
        g.setDefaultEdgeLabel(() => ({}));
        for (const node of nodes) {
            const { width, height } = node.size();
            g.setNode(node.id, { width: Math.round(width) + 10, height: Math.round(height) + 10 });
        }
        for (const edge of graphView.getEdges() as any[]) {
            const srcId = edge.getSourceCellId();
            const tgtId = edge.getTargetCellId();
            if (srcId && tgtId && srcId !== tgtId) {
                g.setEdge(srcId, tgtId, {}, edge.id);
            }
        }
        dagre.layout(g);
        for (const nodeId of g.nodes() as string[]) {
            const n = g.node(nodeId) as { x: number; y: number; width: number; height: number } | undefined;
            if (!n) {
                continue;
            }
            const cell = graphView.getCellById(nodeId);
            if (cell) {
                cell.setPosition(Math.round(n.x - n.width / 2), Math.round(n.y - n.height / 2));
            }
        }
        if (options.fit) {
            fitGraphContent(graphView, options.padding);
        }
        return;
    }

    // Grid mode
    const { rows: targetRows, cols: targetCols, sortBy } = options.grid;
    const sorted = [...nodes].sort((a: any, b: any) => {
        if (sortBy === 'label') {
            return String((a.getData() as NodeData)?.label ?? '').localeCompare(String((b.getData() as NodeData)?.label ?? ''));
        }
        return Number((b.getData() as NodeData)?.degree ?? 0) - Number((a.getData() as NodeData)?.degree ?? 0);
    });
    const count = sorted.length;
    const cols = targetCols ?? (targetRows ? Math.ceil(count / targetRows) : Math.ceil(Math.sqrt(count)));
    sorted.forEach((node: any, i: number) => {
        node.setPosition((i % cols) * 80, Math.floor(i / cols) * 80);
    });
    if (options.fit) {
        fitGraphContent(graphView, options.padding);
    }
}

function fitGraphContent(graphView: any, padding: number): void {
    requestAnimationFrame(() => {
        try {
            if (typeof graphView.fitContent === 'function') {
                graphView.fitContent({ padding, allowNewOrigin: 'any', maxScale: 1 });
            } else if (typeof graphView.zoomToFit === 'function') {
                graphView.zoomToFit({ padding, maxScale: 1 });
            }
        } catch {
            // ignore
        }
    });
}

function centerGraphContent(graphView: any, padding: number): void {
    requestAnimationFrame(() => {
        try {
            if (typeof graphView.centerContent === 'function') {
                graphView.centerContent({ padding });
            }
        } catch {
            // ignore
        }
    });
}

// --- Continuous force engine ---

function startContinuousForceEngine(
    graphView: any,
    graphRoot: HTMLElement,
    options: ForceLayoutOptions,
    grabbedNodes: Set<string>,
): void {
    const velocity = new Map<string, { x: number; y: number }>();
    const dt = 1;
    const axis = resolveAxisBias(options.axisPreference, options.axisStrength);

    const step = (): void => {
        if (!graphRoot.isConnected) {
            return;
        }

        const nodes = graphView.getNodes() as any[];
        const forces = new Map<string, { x: number; y: number }>();

        for (const node of nodes) {
            forces.set(String(node.id), { x: 0, y: 0 });
            if (!velocity.has(String(node.id))) {
                velocity.set(String(node.id), { x: 0, y: 0 });
            }
        }

        // Node-node repulsion
        for (let i = 0; i < nodes.length; i += 1) {
            const a = nodes[i];
            const ap = a.getPosition();
            for (let j = i + 1; j < nodes.length; j += 1) {
                const b = nodes[j];
                const bp = b.getPosition();
                let dx = ap.x - bp.x;
                let dy = ap.y - bp.y;
                let dist2 = dx * dx + dy * dy;
                if (dist2 < 1) {
                    dx = (Math.random() - 0.5) * 2;
                    dy = (Math.random() - 0.5) * 2;
                    dist2 = dx * dx + dy * dy;
                }
                const dist = Math.sqrt(dist2);
                const groupA = String((a.getData() as NodeData)?.group ?? '');
                const groupB = String((b.getData() as NodeData)?.group ?? '');
                const sameGroup = groupA.length > 0 && groupA === groupB;
                const repulsionScale = sameGroup ? 1 : options.interGroupRepulsionFactor;
                const forceMag = (options.repulsion * repulsionScale) / dist2;
                const fx = (dx / dist) * forceMag;
                const fy = (dy / dist) * forceMag;
                const af = forces.get(String(a.id))!;
                const bf = forces.get(String(b.id))!;
                af.x += fx;
                af.y += fy;
                bf.x -= fx;
                bf.y -= fy;

                if (sameGroup && options.intraGroupAttraction > 0) {
                    const attractMag = options.intraGroupAttraction * dist;
                    const ax2 = (dx / dist) * attractMag;
                    const ay2 = (dy / dist) * attractMag;
                    af.x -= ax2;
                    af.y -= ay2;
                    bf.x += ax2;
                    bf.y += ay2;
                }
            }
        }

        // Edge spring forces
        for (const edge of graphView.getEdges() as any[]) {
            const source = edge.getSourceCell();
            const target = edge.getTargetCell();
            if (!source || !target) {
                continue;
            }
            const sp = source.getPosition();
            const tp = target.getPosition();
            let dx = tp.x - sp.x;
            let dy = tp.y - sp.y;
            let dist = Math.sqrt(dx * dx + dy * dy);
            if (dist < 0.001) {
                dist = 0.001;
                dx = 0.001;
                dy = 0;
            }
            const displacement = dist - options.linkDistance;
            const forceMag = options.springStrength * displacement;
            const fx = (dx / dist) * forceMag;
            const fy = (dy / dist) * forceMag;
            const sf = forces.get(String(source.id));
            const tf = forces.get(String(target.id));
            if (sf) { sf.x += fx; sf.y += fy; }
            if (tf) { tf.x -= fx; tf.y -= fy; }
        }

        // Gravity toward origin
        for (const node of nodes) {
            const p = node.getPosition();
            const f = forces.get(String(node.id));
            if (!f) {
                continue;
            }
            f.x += -p.x * options.gravity * axis.centerX;
            f.y += -p.y * options.gravity * axis.centerY;
        }

        // Integrate velocity and position
        for (const node of nodes) {
            if (grabbedNodes.has(String(node.id))) {
                continue;
            }
            const f = forces.get(String(node.id))!;
            const v = velocity.get(String(node.id))!;
            v.x = (v.x + f.x * dt) * options.damping;
            v.y = (v.y + f.y * dt) * options.damping;

            const speed = Math.sqrt(v.x * v.x + v.y * v.y);
            if (speed > options.maxSpeed) {
                const scale = options.maxSpeed / speed;
                v.x *= scale;
                v.y *= scale;
            }

            const p = node.getPosition();
            node.setPosition(p.x + v.x * dt, p.y + v.y * dt);
        }

        requestAnimationFrame(step);
    };

    requestAnimationFrame(step);
}

function resolveAxisBias(
    preference: 'horizontal' | 'vertical' | 'none',
    strength: number,
): { centerX: number; centerY: number } {
    const clamped = Math.max(0, Math.min(1, strength));
    if (preference === 'horizontal') {
        return { centerX: 1 - 0.5 * clamped, centerY: 1 + 3 * clamped };
    }
    if (preference === 'vertical') {
        return { centerX: 1 + 3 * clamped, centerY: 1 - 0.5 * clamped };
    }
    return { centerX: 1, centerY: 1 };
}

// --- Graph stylesheet application ---

function applyGraphStylesheet(
    graphView: any,
    rules: ReadonlyArray<GraphStyleRule>,
    theme: GraphThemePalette,
): void {
    if (rules.length === 0) {
        return;
    }
    for (const rule of rules) {
        if (rule.kind === 'node') {
            for (const node of graphView.getNodes() as any[]) {
                const context = buildNodeContext(node, graphView);
                if (!evaluateExpression(rule.condition, context)) {
                    continue;
                }
                if (rule.group && rule.group.trim().length > 0) {
                    node.setData({ group: rule.group.trim() }, { merge: true });
                }
                applyElementStyle(node, rule.style, 'node', theme);
            }
            continue;
        }
        for (const edge of graphView.getEdges() as any[]) {
            const context = buildEdgeContext(edge, graphView);
            if (!evaluateExpression(rule.condition, context)) {
                continue;
            }
            applyElementStyle(edge, rule.style, 'edge', theme);
        }
    }
}

function applyGraphVisibilityState(graphView: any): void {
    const visibleNodeIds = new Set<string>();
    for (const node of graphView.getNodes() as any[]) {
        const hidden = Boolean((node.getData?.() as { hidden?: boolean } | undefined)?.hidden);
        if (hidden) {
            node.hide();
            continue;
        }
        node.show();
        visibleNodeIds.add(String(node.id));
    }
    for (const edge of graphView.getEdges() as any[]) {
        const hidden = Boolean((edge.getData?.() as { hidden?: boolean } | undefined)?.hidden);
        const sourceId = String(edge.getSourceCellId?.() ?? '');
        const targetId = String(edge.getTargetCellId?.() ?? '');
        if (hidden || !visibleNodeIds.has(sourceId) || !visibleNodeIds.has(targetId)) {
            edge.hide();
            continue;
        }
        edge.show();
    }
}

function buildNodeContext(node: any, graphView: any): NodeContext {
    const nodeId = String(node.id);
    const nodeContext = buildSelectorNodeContext(node, graphView);
    const connectedEdges = (graphView.getConnectedEdges(node) as any[]) ?? [];
    const incoming: SelectorEdgeContext[] = [];
    const outgoing: SelectorEdgeContext[] = [];
    const neighbors = new Map<string, SelectorNodeContext>();

    for (const edge of connectedEdges) {
        const edgeContext = buildEdgeContext(edge, graphView);
        if (edge.getTargetCellId() === nodeId) {
            incoming.push(edgeContext);
            neighbors.set(edgeContext.source.id, edgeContext.source);
        }
        if (edge.getSourceCellId() === nodeId) {
            outgoing.push(edgeContext);
            neighbors.set(edgeContext.target.id, edgeContext.target);
        }
    }

    return {
        value: nodeContext.value,
        incoming,
        outgoing,
        neighbors: [...neighbors.values()],
    };
}

function buildEdgeContext(edge: any, graphView: any): EdgeContext {
    const source = graphView.getCellById(edge.getSourceCellId());
    const target = graphView.getCellById(edge.getTargetCellId());
    return {
        value: String((edge.getData() as { value?: string })?.value ?? ''),
        source: buildSelectorNodeContext(source, graphView),
        target: buildSelectorNodeContext(target, graphView),
    };
}

function buildSelectorNodeContext(node: any, graphView: any): SelectorNodeContext {
    const data = (node?.getData?.() as NodeData | undefined);
    const nodeId = String(node?.id ?? data?.id ?? '');
    return {
        id: nodeId,
        label: String(data?.label ?? shortLabel(data?.value ?? nodeId)),
        value: String(data?.value ?? ''),
        type: resolveNodeTypes(nodeId, graphView),
        degree: Number.isFinite(data?.degree) ? Number(data?.degree) : 0,
        literal: Boolean(data?.literal),
        group: String(data?.group ?? ''),
    };
}

function resolveNodeTypes(nodeId: string, graphView: any): string[] {
    if (!nodeId) {
        return [];
    }
    const node = graphView.getCellById(nodeId);
    if (!node) {
        return [];
    }
    const connectedEdges = (graphView.getConnectedEdges(node) as any[]) ?? [];
    const types: string[] = [];
    for (const edge of connectedEdges) {
        if (edge.getSourceCellId() !== nodeId) {
            continue;
        }
        const predicateValue = String((edge.getData() as { value?: string })?.value ?? '');
        if (predicateValue !== RDF_TYPE_IRI && !predicateValue.endsWith('#type')) {
            continue;
        }
        const target = graphView.getCellById(edge.getTargetCellId());
        const targetValue = String((target?.getData?.() as NodeData | undefined)?.value ?? '');
        if (!targetValue) {
            continue;
        }
        types.push(targetValue);
    }
    return types;
}

function applyElementStyle(
    element: any,
    style: Record<string, string>,
    kind: 'node' | 'edge',
    theme: GraphThemePalette,
): void {
    if (kind === 'node') {
        const bodyAttrs: Record<string, unknown> = {};
        const labelAttrs: Record<string, unknown> = {};
        for (const [rawProperty, rawValue] of Object.entries(style)) {
            const property = rawProperty.trim().toLowerCase();
            const value = String(rawValue);
            if (!property) {
                continue;
            }
            if ((property === 'display' && value.trim().toLowerCase() === 'none')
                || (property === 'visibility' && value.trim().toLowerCase() === 'hidden')
                || (property === 'hidden' && value.trim().toLowerCase() === 'true')) {
                element.setData({ hidden: true }, { merge: true });
                continue;
            }
            const resolved = resolveColorToken(value, theme);
            if (property === 'fill') { bodyAttrs.fill = resolved; continue; }
            if (property === 'stroke') { bodyAttrs.stroke = resolved; continue; }
            if (property === 'stroke-width') { bodyAttrs.strokeWidth = value; continue; }
            if (property === 'color') { labelAttrs.fill = resolved; continue; }
            if (property === 'font-size') { labelAttrs.fontSize = value; continue; }
            const camel = property.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
            bodyAttrs[camel] = resolved;
        }
        const toSet: Record<string, unknown> = {};
        if (Object.keys(bodyAttrs).length > 0) toSet.body = bodyAttrs;
        if (Object.keys(labelAttrs).length > 0) toSet.label = labelAttrs;
        if (Object.keys(toSet).length > 0) {
            element.setAttrs(toSet);
        }
        return;
    }

    // edge
    const lineAttrs: Record<string, unknown> = {};
    let edgeLabelFill: string | undefined;
    for (const [rawProperty, rawValue] of Object.entries(style)) {
        const property = rawProperty.trim().toLowerCase();
        const value = String(rawValue);
        if (!property) {
            continue;
        }
        if ((property === 'display' && value.trim().toLowerCase() === 'none')
            || (property === 'visibility' && value.trim().toLowerCase() === 'hidden')
            || (property === 'hidden' && value.trim().toLowerCase() === 'true')) {
            element.setData({ hidden: true }, { merge: true });
            continue;
        }
        const resolved = resolveColorToken(value, theme);
        if (property === 'stroke') {
            lineAttrs.stroke = resolved;
            lineAttrs.targetMarker = { name: 'classic', size: 6, fill: resolved, stroke: resolved };
            continue;
        }
        if (property === 'stroke-width') { lineAttrs.strokeWidth = value; continue; }
        if (property === 'color') { edgeLabelFill = resolved; continue; }
        if (property === 'font-size') {
            try {
                element.prop('labels/0/attrs/label/fontSize', value);
            } catch {
                // ignore
            }
            continue;
        }
        const camel = property.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
        lineAttrs[camel] = resolved;
    }
    if (Object.keys(lineAttrs).length > 0) {
        element.setAttrs({ line: lineAttrs });
    }
    if (edgeLabelFill) {
        try {
            element.prop('labels/0/attrs/label/fill', edgeLabelFill);
        } catch {
            // ignore
        }
    }
}

// --- Toolbar and neighbourhood filter ---

function installGraphToolbar(graphRoot: HTMLElement, graphView: any, csvDataset: CsvDataset): void {
    const hotspot = document.createElement('div');
    hotspot.className = 'graph-corner-hotspot';
    graphRoot.appendChild(hotspot);

    const toolbar = document.createElement('div');
    toolbar.className = 'graph-corner-toolbar';

    const filterInput = document.createElement('input');
    filterInput.type = 'search';
    filterInput.className = 'tree-filter graph-filter';
    filterInput.placeholder = 'Filter...';
    filterInput.addEventListener('input', () => {
        applyNeighborhoodFilter(graphView, filterInput.value);
    });
    toolbar.appendChild(filterInput);

    const downloadButton = createDownloadButton();
    downloadButton.addEventListener('click', () => {
        csvDataset.downloadCsv(toCsv(csvDataset.columns, csvDataset.rows));
    });
    toolbar.appendChild(downloadButton);

    graphRoot.appendChild(toolbar);

    let hideTimer = 0;
    const showToolbar = () => {
        if (hideTimer) {
            window.clearTimeout(hideTimer);
            hideTimer = 0;
        }
        graphRoot.classList.add('graph-toolbar-visible');
    };
    const scheduleHideToolbar = () => {
        if (document.activeElement === filterInput) {
            return;
        }
        hideTimer = window.setTimeout(() => {
            graphRoot.classList.remove('graph-toolbar-visible');
            hideTimer = 0;
        }, 120);
    };

    hotspot.addEventListener('mouseenter', showToolbar);
    toolbar.addEventListener('mouseenter', showToolbar);
    hotspot.addEventListener('mouseleave', scheduleHideToolbar);
    toolbar.addEventListener('mouseleave', scheduleHideToolbar);
    filterInput.addEventListener('focus', showToolbar);
    filterInput.addEventListener('blur', scheduleHideToolbar);

    const swallowPointer = (event: Event) => {
        event.stopPropagation();
    };
    toolbar.addEventListener('pointerdown', swallowPointer, true);
    toolbar.addEventListener('mousedown', swallowPointer, true);
    filterInput.addEventListener('pointerdown', swallowPointer, true);
    filterInput.addEventListener('mousedown', swallowPointer, true);
    downloadButton.addEventListener('pointerdown', swallowPointer, true);
    downloadButton.addEventListener('mousedown', swallowPointer, true);
}

function applyNeighborhoodFilter(graphView: any, rawQuery: string): void {
    const query = rawQuery.trim().toLowerCase();
    const allNodes = graphView.getNodes() as any[];
    const allEdges = graphView.getEdges() as any[];
    const baselineHiddenNodeIds = new Set(
        allNodes
            .filter((node) => Boolean((node.getData?.() as { hidden?: boolean } | undefined)?.hidden))
            .map((node) => String(node.id))
    );
    const baselineHiddenEdgeIds = new Set(
        allEdges
            .filter((edge) => Boolean((edge.getData?.() as { hidden?: boolean } | undefined)?.hidden))
            .map((edge) => String(edge.id))
    );

    if (!query) {
        for (const node of allNodes) {
            if (baselineHiddenNodeIds.has(String(node.id))) {
                node.hide();
            } else {
                node.show();
            }
        }
        for (const edge of allEdges) {
            if (baselineHiddenEdgeIds.has(String(edge.id))) {
                edge.hide();
            } else {
                edge.show();
            }
        }
        return;
    }

    const matchedIds = new Set<string>();
    for (const node of allNodes) {
        if (baselineHiddenNodeIds.has(String(node.id))) {
            continue;
        }
        const nodeData = node.getData() as NodeData;
        const label = String(nodeData?.label ?? '').toLowerCase();
        const value = String(nodeData?.value ?? '').toLowerCase();
        if (label.includes(query) || value.includes(query)) {
            matchedIds.add(String(node.id));
        }
    }

    // Include one-hop neighbours of matched nodes.
    const keepNodeIds = new Set<string>(matchedIds);
    for (const id of matchedIds) {
        const cell = graphView.getCellById(id);
        if (!cell) {
            continue;
        }
        const connectedEdges = (graphView.getConnectedEdges(cell) ?? []) as any[];
        for (const edge of connectedEdges) {
            keepNodeIds.add(edge.getSourceCellId());
            keepNodeIds.add(edge.getTargetCellId());
        }
    }

    for (const node of allNodes) {
        if (keepNodeIds.has(String(node.id)) && !baselineHiddenNodeIds.has(String(node.id))) {
            node.show();
        } else {
            node.hide();
        }
    }

    // Show only edges whose both endpoints are visible.
    for (const edge of allEdges) {
        const srcId = edge.getSourceCellId();
        const tgtId = edge.getTargetCellId();
        if (!baselineHiddenEdgeIds.has(String(edge.id))
            && keepNodeIds.has(srcId)
            && keepNodeIds.has(tgtId)
            && !baselineHiddenNodeIds.has(String(srcId))
            && !baselineHiddenNodeIds.has(String(tgtId))) {
            edge.show();
        } else {
            edge.hide();
        }
    }
}

function createDownloadButton(): HTMLButtonElement {
    const downloadButton = document.createElement('button');
    downloadButton.className = 'tree-download-btn';
    downloadButton.title = 'Download CSV';
    downloadButton.setAttribute('aria-label', 'Download CSV');
    const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    iconSvg.setAttribute('viewBox', '0 0 24 24');
    iconSvg.setAttribute('width', '20');
    iconSvg.setAttribute('height', '20');
    iconSvg.setAttribute('aria-hidden', 'true');
    iconSvg.setAttribute('focusable', 'false');
    iconSvg.style.fill = 'currentColor';
    const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    iconPath.setAttribute('d', 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z');
    iconSvg.appendChild(iconPath);
    downloadButton.appendChild(iconSvg);
    return downloadButton;
}

// --- Theme resolution ---

function resolveThemePalette(): GraphThemePalette {
    const styles = getComputedStyle(document.body);
    const foreground = readCssVar(
        styles,
        '--vscode-editor-foreground',
        readCssVar(styles, '--oml-static-foreground', '#24292f'),
    );
    const background = readCssVar(
        styles,
        '--vscode-editor-background',
        readCssVar(styles, '--oml-static-background', '#ffffff'),
    );
    const muted = readCssVar(
        styles,
        '--vscode-descriptionForeground',
        readCssVar(styles, '--oml-static-muted', '#57606a'),
    );
    const border = readCssVar(
        styles,
        '--vscode-editorWidget-border',
        readCssVar(styles, '--oml-static-border', '#d0d7de'),
    );
    const link = readCssVar(
        styles,
        '--vscode-textLink-foreground',
        readCssVar(styles, '--oml-static-link', '#0969da'),
    );
    const accent = readCssVar(styles, '--vscode-button-background', link);
    const accentForeground = readCssVar(styles, '--vscode-button-foreground', '#ffffff');
    const selected = readCssVar(styles, '--vscode-focusBorder', link);
    const nodeFill = readCssVar(styles, '--vscode-button-secondaryBackground', accent);
    const nodeText = autoContrastingText(background);
    const nodeStroke = readCssVar(styles, '--vscode-button-border', border);
    const literalFill = readCssVar(styles, '--vscode-badge-background', muted);
    const literalText = autoContrastingText(background);
    const fontFamily = readCssVar(
        styles,
        '--vscode-editor-font-family',
        '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
    );
    const edgeLabelText = border;
    const edgeLabelBg = background;
    return {
        nodeFill,
        nodeStroke,
        nodeText,
        literalFill,
        literalStroke: border,
        literalText,
        edgeStroke: border,
        edgeText: foreground,
        edgeLabelBg,
        edgeLabelText,
        selectedStroke: selected,
        fontFamily,
        foreground,
        background,
        muted,
        link,
        accent,
        accentForeground,
        border,
    };
}

// --- Stylesheet compilation ---

function compileGraphStylesheet(options: Record<string, unknown> | undefined): GraphStyleRule[] {
    const raw = options?.stylesheet;
    if (!Array.isArray(raw)) {
        return [];
    }
    const rules: GraphStyleRule[] = [];
    for (const entry of raw) {
        if (!isRecord(entry)) {
            continue;
        }
        const selector = parseGraphSelector(entry.selector);
        if (!selector) {
            continue;
        }
        const style = normalizeStyle(entry.style);
        if (Object.keys(style).length === 0) {
            continue;
        }
        rules.push({
            kind: selector.kind,
            condition: selector.condition,
            style,
            group: selector.kind === 'node' && typeof entry.group === 'string' ? entry.group : undefined,
        });
    }
    return rules;
}

function parseGraphSelector(rawSelector: unknown): { kind: 'node' | 'edge'; condition?: string } | undefined {
    if (typeof rawSelector !== 'string') {
        return undefined;
    }
    const trimmed = rawSelector.trim();
    if (!trimmed) {
        return undefined;
    }
    const match = /^(node|edge)(?:\s*\[(.*)\])?$/i.exec(trimmed);
    if (!match) {
        return undefined;
    }
    const kind = match[1].toLowerCase() as 'node' | 'edge';
    const condition = match[2]?.trim();
    return { kind, condition: condition && condition.length > 0 ? condition : undefined };
}

function normalizeStyle(rawStyle: unknown): Record<string, string> {
    if (!isRecord(rawStyle)) {
        return {};
    }
    const style: Record<string, string> = {};
    for (const [property, rawValue] of Object.entries(rawStyle)) {
        if (!property.trim()) {
            continue;
        }
        if (rawValue === undefined || rawValue === null) {
            continue;
        }
        style[property] = String(rawValue);
    }
    return style;
}

function evaluateExpression(condition: string | undefined, context: object): boolean {
    if (!condition) {
        return true;
    }
    try {
        const scope = Object.assign(Object.create(null), context) as Record<string, unknown>;
        const keys = Object.keys(scope);
        const values = keys.map((key) => scope[key]);
        // eslint-disable-next-line no-new-func
        const evaluator = new Function(...keys, `"use strict"; return (${condition});`);
        return Boolean(evaluator(...values));
    } catch {
        return false;
    }
}

// --- Layout options parsing ---

function resolveLayoutOptions(options: Record<string, unknown> | undefined): ResolvedLayout {
    const layout = isRecord(options?.layout) ? options.layout : {};
    const mode = parseMode(layout.mode);
    const force = isRecord(layout.force) ? layout.force : {};
    const forceGroup = isRecord(force.group) ? force.group : {};
    const radial = isRecord(layout.radial) ? layout.radial : {};
    const dag = isRecord(layout.dag) ? layout.dag : {};
    const grid = isRecord(layout.grid) ? layout.grid : {};
    return {
        mode,
        running: mode === 'force' ? asBoolean(layout.running, true) : false,
        fit: asBoolean(layout.fit, true),
        padding: asNumber(layout.padding, 24),
        force: {
            repulsion: asNumber(force.repulsion, 12_000),
            linkDistance: asNumber(force.linkDistance, 110),
            springStrength: asNumber(force.springStrength, 0.006),
            gravity: asNumber(force.gravity, 0.0009),
            damping: asNumber(force.damping, 0.86),
            maxSpeed: asNumber(force.maxSpeed, 8),
            intraGroupAttraction: asNumber(forceGroup.intraAttraction, 0),
            interGroupRepulsionFactor: asNumber(forceGroup.interRepulsionFactor, 1),
            axisPreference: force.axisPreference === 'vertical'
                ? 'vertical'
                : force.axisPreference === 'none'
                    ? 'none'
                    : 'horizontal',
            axisStrength: asNumber(force.axisStrength, 0.55),
        },
        radial: {
            startAngle: asNumber(radial.startAngle, -Math.PI / 2),
            clockwise: asBoolean(radial.clockwise, true),
            sortBy: radial.sortBy === 'label' ? 'label' : 'degree',
        },
        dag: {
            rankDir: dag.rankDir === 'LR' ? 'LR' : 'TB',
            nodeSep: asNumber(dag.nodeSep, 40),
            rankSep: asNumber(dag.rankSep, 80),
            acyclic: asBoolean(dag.acyclic, true),
        },
        grid: {
            rows: asOptionalNumber(grid.rows),
            cols: asOptionalNumber(grid.cols),
            sortBy: grid.sortBy === 'label' ? 'label' : 'degree',
            condense: asBoolean(grid.condense, false),
        },
    };
}

function parseMode(value: unknown): 'force' | 'radial' | 'dag' | 'grid' {
    if (value === 'radial' || value === 'dag' || value === 'grid') {
        return value;
    }
    return 'force';
}

// --- Canvas dimension helpers ---

function resolveCanvasHeight(options: Record<string, unknown> | undefined): string {
    const canvas = isRecord(options?.canvas) ? options.canvas : undefined;
    const raw = canvas?.height ?? options?.height;
    const minHeight = numericCanvasMinHeight(options);
    if (typeof raw === 'number' && Number.isFinite(raw) && raw >= minHeight) {
        return `${raw}px`;
    }
    if (typeof raw === 'string') {
        const trimmed = raw.trim();
        if (/^\d+(\.\d+)?(px|vh|%)$/.test(trimmed)) {
            return trimmed;
        }
    }
    return '460px';
}

function resolveCanvasMinHeight(options: Record<string, unknown> | undefined): string {
    return `${numericCanvasMinHeight(options)}px`;
}

function resolveExpandOnClickOption(options: Record<string, unknown> | undefined): boolean {
    return asBoolean(options?.expandOnClick, false);
}

function numericCanvasMinHeight(options: Record<string, unknown> | undefined): number {
    const canvas = isRecord(options?.canvas) ? options.canvas : undefined;
    const raw = canvas?.minHeight ?? options?.minHeight;
    if (typeof raw === 'number' && Number.isFinite(raw)) {
        return Math.max(120, Math.round(raw));
    }
    return 220;
}

// --- Utility helpers ---

function asNumber(value: unknown, fallback: number): number {
    if (typeof value === 'number' && Number.isFinite(value)) {
        return value;
    }
    return fallback;
}

function asOptionalNumber(value: unknown): number | undefined {
    if (typeof value === 'number' && Number.isFinite(value)) {
        return value;
    }
    return undefined;
}

function asBoolean(value: unknown, fallback: boolean): boolean {
    if (typeof value === 'boolean') {
        return value;
    }
    return fallback;
}

function readCssVar(styles: CSSStyleDeclaration, name: string, fallback: string): string {
    const value = styles.getPropertyValue(name).trim();
    return value.length > 0 ? value : fallback;
}

function parseRgbColor(value: string): { r: number; g: number; b: number } | undefined {
    const match = /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)/i.exec(value.trim());
    if (!match) {
        return undefined;
    }
    const r = Number.parseFloat(match[1]);
    const g = Number.parseFloat(match[2]);
    const b = Number.parseFloat(match[3]);
    if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) {
        return undefined;
    }
    return {
        r: Math.max(0, Math.min(255, Math.round(r))),
        g: Math.max(0, Math.min(255, Math.round(g))),
        b: Math.max(0, Math.min(255, Math.round(b))),
    };
}

function parseBrowserColor(value: string): { r: number; g: number; b: number } | undefined {
    const probe = document.createElement('span');
    probe.style.color = '';
    probe.style.color = value.trim();
    if (!probe.style.color) {
        return undefined;
    }
    probe.style.position = 'absolute';
    probe.style.left = '-9999px';
    probe.style.top = '-9999px';
    document.body.appendChild(probe);
    const resolved = getComputedStyle(probe).color;
    probe.remove();
    return parseRgbColor(resolved);
}

function parseCssColor(value: string): { r: number; g: number; b: number } | undefined {
    return parseHexColor(value) ?? parseRgbColor(value) ?? parseBrowserColor(value);
}

function parseHexColor(value: string): { r: number; g: number; b: number } | undefined {
    const trimmed = value.trim();
    const match = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(trimmed);
    if (!match) {
        return undefined;
    }
    const hex = match[1];
    if (hex.length === 3) {
        const r = Number.parseInt(hex[0] + hex[0], 16);
        const g = Number.parseInt(hex[1] + hex[1], 16);
        const b = Number.parseInt(hex[2] + hex[2], 16);
        return { r, g, b };
    }
    const r = Number.parseInt(hex.slice(0, 2), 16);
    const g = Number.parseInt(hex.slice(2, 4), 16);
    const b = Number.parseInt(hex.slice(4, 6), 16);
    return { r, g, b };
}

function relativeLuminance(color: { r: number; g: number; b: number }): number {
    const toLinear = (channel: number): number => {
        const c = channel / 255;
        return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
    };
    const r = toLinear(color.r);
    const g = toLinear(color.g);
    const b = toLinear(color.b);
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function contrastRatio(a: string, b: string): number {
    const ca = parseCssColor(a);
    const cb = parseCssColor(b);
    if (!ca || !cb) {
        return 1;
    }
    const la = relativeLuminance(ca);
    const lb = relativeLuminance(cb);
    const light = Math.max(la, lb);
    const dark = Math.min(la, lb);
    return (light + 0.05) / (dark + 0.05);
}

function autoContrastingText(background: string): string {
    const dark = '#111111';
    const light = '#f5f5f5';
    return contrastRatio(background, dark) >= contrastRatio(background, light) ? dark : light;
}

function resolveColorToken(value: string, theme: GraphThemePalette): string {
    const trimmed = value.trim();
    if (trimmed === 'theme.node') return theme.nodeFill;
    if (trimmed === 'theme.nodeStroke') return theme.nodeStroke;
    if (trimmed === 'theme.nodeText') return theme.nodeText;
    if (trimmed === 'theme.literal') return theme.literalFill;
    if (trimmed === 'theme.literalStroke') return theme.literalStroke;
    if (trimmed === 'theme.literalText') return theme.literalText;
    if (trimmed === 'theme.edge') return theme.edgeStroke;
    if (trimmed === 'theme.edgeText') return theme.edgeText;
    if (trimmed === 'theme.edgeLabelBg') return theme.edgeLabelBg;
    if (trimmed === 'theme.edgeLabelText') return theme.edgeLabelText;
    if (trimmed === 'theme.selected') return theme.selectedStroke;
    if (trimmed === 'theme.foreground') return theme.foreground;
    if (trimmed === 'theme.background') return theme.background;
    if (trimmed === 'theme.muted') return theme.muted;
    if (trimmed === 'theme.link') return theme.link;
    if (trimmed === 'theme.accent') return theme.accent;
    if (trimmed === 'theme.accentForeground') return theme.accentForeground;
    if (trimmed === 'theme.border') return theme.border;
    return trimmed;
}

function normalizeGraphExpandPayload(value: unknown): GraphExpandPayload | undefined {
    if (!isRecord(value)) {
        return undefined;
    }
    const columns = Array.isArray(value.columns)
        ? value.columns.filter((entry): entry is string => typeof entry === 'string')
        : [];
    const rows = Array.isArray(value.rows)
        ? value.rows.map((row) => Array.isArray(row)
            ? row.map((cell) => (typeof cell === 'string' ? cell : String(cell ?? '')))
            : [])
        : [];
    if (columns.length === 0 || rows.length === 0) {
        return undefined;
    }
    return { columns, rows };
}

function mergeExpandedGraphData(
    graphView: any,
    state: GraphMutableState,
    payload: GraphExpandPayload,
): void {
    const subjectIndex = payload.columns.indexOf('subject');
    const predicateIndex = payload.columns.indexOf('predicate');
    const objectIndex = payload.columns.indexOf('object');
    if (subjectIndex < 0 || predicateIndex < 0 || objectIndex < 0) {
        return;
    }

    const ensureNodeInState = (rawValue: string): NodeData => {
        const nodeId = toNodeId(rawValue);
        const existing = state.nodesById.get(nodeId);
        if (existing) {
            return existing;
        }
        const node: NodeData = {
            id: nodeId,
            label: shortLabel(rawValue),
            value: rawValue,
            degree: 0,
            literal: isLiteralValue(rawValue),
            group: '',
        };
        state.nodesById.set(nodeId, node);
        graphView.addNode(buildNodeDef(node, computeNodeSize(node.degree, state.minDegree, state.maxDegree)));
        return node;
    };

    let hasChanges = false;
    for (const row of payload.rows) {
        const subject = row[subjectIndex] ?? '';
        const predicate = row[predicateIndex] ?? '';
        const object = row[objectIndex] ?? '';
        if (!subject || !predicate || !object) {
            continue;
        }
        const sourceNode = ensureNodeInState(subject);
        const targetNode = ensureNodeInState(object);
        const edgeKey = `${sourceNode.id}|${toNodeId(predicate)}|${targetNode.id}`;
        if (state.edgeKeys.has(edgeKey)) {
            continue;
        }
        state.edgeKeys.add(edgeKey);
        sourceNode.degree += 1;
        targetNode.degree += 1;
        state.minDegree = Math.min(state.minDegree, sourceNode.degree, targetNode.degree);
        state.maxDegree = Math.max(state.maxDegree, sourceNode.degree, targetNode.degree);
        graphView.addEdge(buildEdgeDef({
            id: `${edgeKey}|${state.edgeSequence}`,
            source: sourceNode.id,
            target: targetNode.id,
            value: predicate,
            label: shortLabel(predicate),
        }));
        state.edgeSequence += 1;
        hasChanges = true;
    }

    if (!hasChanges) {
        return;
    }

    for (const node of graphView.getNodes() as any[]) {
        const data = node.getData?.() as NodeData | undefined;
        const degree = Number(data?.degree ?? 0);
        const size = computeNodeSize(degree, state.minDegree, state.maxDegree);
        node.resize(size, size + NODE_LABEL_HEIGHT);
        if (node.shape === 'graph-node-literal') {
            node.attr({
                body: {
                    width: size,
                    height: size,
                },
            });
            continue;
        }
        node.attr({
            body: {
                r: size / 2,
                cx: size / 2,
                cy: size / 2,
            },
        });
    }
}

function ensureNode(nodes: Map<string, NodeData>, rawValue: string): void {
    const id = toNodeId(rawValue);
    if (nodes.has(id)) {
        return;
    }
    nodes.set(id, {
        id,
        label: shortLabel(rawValue),
        value: rawValue,
        degree: 0,
        literal: isLiteralValue(rawValue),
        group: '',
    });
}

function isLiteralValue(value: string): boolean {
    return !/^https?:\/\//i.test(value) && !value.startsWith('_:');
}

function toNodeId(value: string): string {
    return encodeURIComponent(value);
}

function toCsv(columns: string[], rows: string[][]): string {
    const escape = (value: string): string => {
        if (/[",\n\r]/.test(value)) {
            return `"${value.replace(/"/g, '""')}"`;
        }
        return value;
    };
    const lines: string[] = [];
    lines.push(columns.map((value) => escape(String(value ?? ''))).join(','));
    for (const row of rows) {
        lines.push(row.map((value) => escape(String(value ?? ''))).join(','));
    }
    return lines.join('\n');
}

function shortLabel(value: string): string {
    if (value.startsWith('_:')) {
        return value;
    }
    if (isIriValue(value)) {
        return shortLabelFromIri(value);
    }
    return displayLabelFromIri(value);
}

function isRecord(value: unknown): value is Record<string, unknown> {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}
