"""Export the knowledge graph to HTML, Plotly HTML, JSON, graph-data.js, and GEXF."""

from __future__ import annotations

import json
import math
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Any

from importlib.resources import files as resource_files

import pyjson5

from .branding import APP_NAME, APP_VERSION
from .config import GraphVisibility, normalize_graph_visibility
from .engine import GraphEngine
from .graph_filters import filtered_engine
from .models import KnowledgeGraph


_NODE_COLORS: dict[str, str] = {
    "Carcinogen": "#e05565",
    "Enzyme": "#4f98a3",
    "Gene": "#3d8b8b",
    "Metabolite": "#e8945a",
    "DNA_Adduct": "#a86fdf",
    "Pathway": "#5591c7",
    "Tissue": "#c2855a",
}

_NODE_SIZES: dict[str, int] = {
    "Carcinogen": 26,
    "Enzyme": 20,
    "Gene": 18,
    "Metabolite": 18,
    "DNA_Adduct": 20,
    "Pathway": 24,
    "Tissue": 17,
}

_NODE_SYMBOLS: dict[str, str] = {
    "Carcinogen": "diamond",
    "Enzyme": "circle",
    "Gene": "circle",
    "Metabolite": "circle",
    "DNA_Adduct": "square",
    "Pathway": "hexagon",
    "Tissue": "triangle-up",
}

_EDGE_COLORS: dict[str, str] = {
    "ACTIVATES": "#e05565",
    "DETOXIFIES": "#6daa45",
    "TRANSPORTS": "#5591c7",
    "FORMS_ADDUCT": "#a86fdf",
    "REPAIRS": "#e8af34",
    "PATHWAY": "#707a8a",
    "EXPRESSED_IN": "#c2855a",
    "INDUCES": "#d4a843",
    "INHIBITS": "#8b4a6b",
    "ENCODES": "#3d8b8b",
    "CUSTOM": "#9ea9bd",
}


def _builtin_viewer_template() -> str:
    """Return a minimal interactive HTML viewer template.

    This fallback keeps HTML export working when no checked-in D3 viewer bundle
    is present in the repo. It intentionally uses only inline CSS/JS so the
    exported file remains self-contained once ``GRAPH_DATA`` is embedded.
    """
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{APP_NAME} Viewer</title>
  <style>
    :root {{
      --bg: #08111f;
      --panel: rgba(8, 17, 31, 0.82);
      --panel-border: rgba(142, 176, 205, 0.22);
      --text: #e6edf7;
      --muted: #95a6bc;
      --accent: #76c3ff;
      --node-stroke: rgba(8, 17, 31, 0.88);
      --edge: rgba(159, 178, 199, 0.42);
      --dim: 0.16;
    }}
    * {{ box-sizing: border-box; }}
    body {{
      margin: 0;
      font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background:
        radial-gradient(circle at top, rgba(50, 110, 167, 0.28), transparent 42%),
        linear-gradient(180deg, #0d1b2a 0%, #08111f 100%);
      color: var(--text);
      min-height: 100vh;
    }}
    .app {{
      max-width: 1600px;
      margin: 0 auto;
      padding: 28px;
    }}
    .header {{
      display: flex;
      justify-content: space-between;
      gap: 16px;
      align-items: end;
      margin-bottom: 18px;
    }}
    .title {{
      margin: 0;
      font-size: 1.8rem;
      letter-spacing: 0.02em;
    }}
    .subtitle {{
      margin: 6px 0 0;
      color: var(--muted);
      max-width: 48rem;
      line-height: 1.5;
    }}
    .toolbar {{
      display: flex;
      flex-wrap: wrap;
      gap: 12px;
      align-items: center;
      margin-bottom: 18px;
    }}
    .toolbar input {{
      min-width: 260px;
      flex: 1 1 320px;
      border: 1px solid var(--panel-border);
      background: rgba(7, 15, 28, 0.84);
      color: var(--text);
      border-radius: 12px;
      padding: 12px 14px;
      outline: none;
    }}
    .toolbar button {{
      border: 1px solid var(--panel-border);
      background: rgba(11, 25, 44, 0.84);
      color: var(--text);
      border-radius: 12px;
      padding: 12px 14px;
      cursor: pointer;
    }}
    .toolbar button:hover {{
      border-color: rgba(118, 195, 255, 0.48);
    }}
    .summary {{
      color: var(--muted);
      font-size: 0.95rem;
      white-space: nowrap;
    }}
    .layout {{
      display: grid;
      grid-template-columns: minmax(0, 1fr) 320px;
      gap: 18px;
    }}
    .card {{
      background: var(--panel);
      border: 1px solid var(--panel-border);
      border-radius: 18px;
      backdrop-filter: blur(8px);
      box-shadow: 0 22px 60px rgba(0, 0, 0, 0.22);
      overflow: hidden;
    }}
    .graph-wrap {{
      min-height: 760px;
      padding: 10px;
    }}
    #graph {{
      width: 100%;
      height: 760px;
      display: block;
      background:
        radial-gradient(circle at center, rgba(118, 195, 255, 0.08), transparent 46%),
        linear-gradient(180deg, rgba(7, 15, 28, 0.7), rgba(7, 15, 28, 0.92));
      border-radius: 14px;
    }}
    .detail {{
      padding: 18px;
    }}
    .detail h2 {{
      margin: 0 0 8px;
      font-size: 1.15rem;
    }}
    .detail p {{
      margin: 0 0 12px;
      color: var(--muted);
      line-height: 1.5;
    }}
    .detail dl {{
      margin: 0;
      display: grid;
      grid-template-columns: 110px minmax(0, 1fr);
      gap: 8px 10px;
      font-size: 0.92rem;
    }}
    .detail dt {{
      color: var(--muted);
    }}
    .detail dd {{
      margin: 0;
      overflow-wrap: anywhere;
    }}
    .legend {{
      margin-top: 16px;
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
    }}
    .chip {{
      display: inline-flex;
      align-items: center;
      gap: 8px;
      border: 1px solid var(--panel-border);
      border-radius: 999px;
      padding: 6px 10px;
      color: var(--muted);
      font-size: 0.82rem;
    }}
    .chip::before {{
      content: "";
      width: 10px;
      height: 10px;
      border-radius: 50%;
      background: var(--chip-color, var(--accent));
    }}
    .edge {{
      stroke: var(--edge-color, var(--edge));
      stroke-width: 2.2;
      opacity: 0.85;
    }}
    .edge.dimmed,
    .node-group.dimmed,
    .label.dimmed {{
      opacity: var(--dim);
    }}
    .node {{
      stroke: var(--node-stroke);
      stroke-width: 2.5;
      cursor: grab;
    }}
    .node:active {{
      cursor: grabbing;
    }}
    .node.selected {{
      stroke: #ffffff;
      stroke-width: 3;
    }}
    .label {{
      fill: var(--text);
      font-size: 12px;
      font-weight: 600;
      pointer-events: none;
      text-anchor: middle;
    }}
    .footer {{
      margin-top: 14px;
      color: var(--muted);
      font-size: 0.8rem;
    }}
    @media (max-width: 1080px) {{
      .layout {{
        grid-template-columns: 1fr;
      }}
      #graph {{
        height: 560px;
      }}
    }}
  </style>
  <script src="./graph-data.js"></script>
</head>
<body>
  <div class="app">
    <div class="header">
      <div>
        <h1 class="title">{APP_NAME} Graph Viewer</h1>
        <p class="subtitle">Self-contained fallback viewer for exported knowledge graphs. Search, click, and drag nodes to inspect the graph without a checked-in viewer bundle.</p>
      </div>
      <div class="summary" id="summary"></div>
    </div>
    <div class="toolbar">
      <input id="search" type="search" placeholder="Search nodes by id, label, type, detail, or source..." />
      <button id="reset">Reset Focus</button>
    </div>
    <div class="layout">
      <div class="card graph-wrap">
        <svg id="graph" viewBox="0 0 1200 760" role="img" aria-label="Knowledge graph"></svg>
      </div>
      <aside class="card detail">
        <h2 id="detail-title">Graph Overview</h2>
        <p id="detail-text">Click a node to inspect its metadata. Use search to highlight matching nodes and their immediate neighbors.</p>
        <dl id="detail-meta"></dl>
        <div class="legend" id="legend"></div>
      </aside>
    </div>
    <div class="footer">Generated by {APP_NAME} v{APP_VERSION}</div>
  </div>
  <script>
    (() => {{
      const graphData = typeof GRAPH_DATA !== "undefined" ? GRAPH_DATA : {{ nodes: [], edges: [] }};
      const svg = document.getElementById("graph");
      const summary = document.getElementById("summary");
      const search = document.getElementById("search");
      const reset = document.getElementById("reset");
      const detailTitle = document.getElementById("detail-title");
      const detailText = document.getElementById("detail-text");
      const detailMeta = document.getElementById("detail-meta");
      const legend = document.getElementById("legend");
      const width = 1200;
      const height = 760;
      const ns = "http://www.w3.org/2000/svg";
      const colorByType = {{
        Carcinogen: "#e05565",
        Enzyme: "#4f98a3",
        Gene: "#3d8b8b",
        Metabolite: "#e8945a",
        DNA_Adduct: "#a86fdf",
        Pathway: "#5591c7",
        Tissue: "#c2855a",
      }};
      const colorByEdge = {{
        ACTIVATES: "#e05565",
        DETOXIFIES: "#6daa45",
        TRANSPORTS: "#5591c7",
        FORMS_ADDUCT: "#a86fdf",
        REPAIRS: "#e8af34",
        PATHWAY: "#707a8a",
        EXPRESSED_IN: "#c2855a",
        INDUCES: "#d4a843",
        INHIBITS: "#8b4a6b",
        ENCODES: "#3d8b8b",
        CUSTOM: "#9ea9bd",
      }};

      const nodes = (graphData.nodes || []).map((node, index, arr) => {{
        const angle = (Math.PI * 2 * index) / Math.max(arr.length, 1);
        const radius = Math.min(width, height) * 0.34 + (index % 5) * 10;
        return {{
          ...node,
          x: width / 2 + Math.cos(angle) * radius,
          y: height / 2 + Math.sin(angle) * radius,
        }};
      }});
      const edges = (graphData.edges || []).map((edge) => ({{ ...edge }}));
      const nodeById = new Map(nodes.map((node) => [node.id, node]));
      const neighbors = new Map(nodes.map((node) => [node.id, new Set()]));
      edges.forEach((edge) => {{
        neighbors.get(edge.source)?.add(edge.target);
        neighbors.get(edge.target)?.add(edge.source);
      }});

      let searchTerm = "";
      let selectedId = null;
      let draggingId = null;

      function svgPoint(evt) {{
        const point = svg.createSVGPoint();
        point.x = evt.clientX;
        point.y = evt.clientY;
        return point.matrixTransform(svg.getScreenCTM().inverse());
      }}

      function createEl(tag, attrs = {{}}, text = "") {{
        const el = document.createElementNS(ns, tag);
        Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, String(value)));
        if (text) {{
          el.textContent = text;
        }}
        return el;
      }}

      function clear(el) {{
        while (el.firstChild) {{
          el.removeChild(el.firstChild);
        }}
      }}

      function nodeMatches(node, term) {{
        if (!term) {{
          return true;
        }}
        const haystack = [
          node.id,
          node.label,
          node.type,
          node.detail,
          node.group,
          node.source_db,
          node.match_status,
          node.origin,
        ].filter(Boolean).join(" ").toLowerCase();
        return haystack.includes(term);
      }}

      function highlightedIds() {{
        const term = searchTerm.trim().toLowerCase();
        if (!term) {{
          return null;
        }}
        const matches = new Set(nodes.filter((node) => nodeMatches(node, term)).map((node) => node.id));
        for (const nodeId of Array.from(matches)) {{
          for (const neighbor of neighbors.get(nodeId) || []) {{
            matches.add(neighbor);
          }}
        }}
        return matches;
      }}

      function setDetails(node) {{
        clear(detailMeta);
        if (!node) {{
          detailTitle.textContent = "Graph Overview";
          detailText.textContent = "Click a node to inspect its metadata. Use search to highlight matching nodes and their immediate neighbors.";
          return;
        }}

        detailTitle.textContent = node.label || node.id;
        detailText.textContent = node.detail || "No detail available for this node.";

        const fields = [
          ["ID", node.id],
          ["Type", node.type],
          ["Origin", node.origin],
          ["Match", node.match_status],
          ["Group", node.group],
          ["IARC", node.iarc],
          ["Phase", node.phase],
          ["Role", node.role],
          ["Reactivity", node.reactivity],
          ["Tissue", node.tissue],
          ["Variant", node.variant],
          ["Phenotype", node.phenotype],
          ["Source", node.source_db],
          ["Evidence", node.evidence],
          ["PMID", node.pmid],
          ["Canonical", node.canonical_label || node.canonical_id],
        ].filter(([, value]) => value !== undefined && value !== null && value !== "");

        for (const [label, value] of fields) {{
          const dt = document.createElement("dt");
          dt.textContent = label;
          const dd = document.createElement("dd");
          dd.textContent = String(value);
          detailMeta.appendChild(dt);
          detailMeta.appendChild(dd);
        }}
      }}

      function renderLegend() {{
        clear(legend);
        const usedTypes = Array.from(new Set(nodes.map((node) => node.type))).sort();
        for (const type of usedTypes) {{
          const chip = document.createElement("span");
          chip.className = "chip";
          chip.style.setProperty("--chip-color", colorByType[type] || "#76c3ff");
          chip.textContent = type;
          legend.appendChild(chip);
        }}
      }}

      function render() {{
        clear(svg);

        const defs = createEl("defs");
        const marker = createEl("marker", {{
          id: "arrow",
          markerWidth: 10,
          markerHeight: 10,
          refX: 8,
          refY: 3,
          orient: "auto",
          markerUnits: "strokeWidth",
        }});
        marker.appendChild(createEl("path", {{ d: "M0,0 L0,6 L9,3 z", fill: "#8ea4bb" }}));
        defs.appendChild(marker);
        svg.appendChild(defs);

        const focusIds = highlightedIds();
        summary.textContent = `${{nodes.length}} nodes, ${{edges.length}} edges`;

        edges.forEach((edge) => {{
          const source = nodeById.get(edge.source);
          const target = nodeById.get(edge.target);
          if (!source || !target) {{
            return;
          }}
          const line = createEl("line", {{
            x1: source.x,
            y1: source.y,
            x2: target.x,
            y2: target.y,
            class: "edge",
            "marker-end": "url(#arrow)",
          }});
          line.style.setProperty("--edge-color", colorByEdge[edge.type] || "#8ea4bb");
          if (focusIds && (!focusIds.has(edge.source) || !focusIds.has(edge.target))) {{
            line.classList.add("dimmed");
          }}
          const title = createEl("title", {{}}, `${{edge.type}}: ${{edge.source}} -> ${{edge.target}}`);
          line.appendChild(title);
          svg.appendChild(line);
        }});

        nodes.forEach((node) => {{
          const group = createEl("g", {{ class: "node-group" }});
          if (focusIds && !focusIds.has(node.id)) {{
            group.classList.add("dimmed");
          }}

          const circle = createEl("circle", {{
            cx: node.x,
            cy: node.y,
            r: selectedId === node.id ? 18 : 15,
            class: "node" + (selectedId === node.id ? " selected" : ""),
            fill: colorByType[node.type] || "#76c3ff",
          }});
          circle.addEventListener("mousedown", (evt) => {{
            draggingId = node.id;
            evt.preventDefault();
          }});
          circle.addEventListener("click", () => {{
            selectedId = node.id;
            setDetails(node);
            render();
          }});
          circle.appendChild(createEl("title", {{}}, `${{node.label || node.id}} (${{node.type}})`));

          const label = createEl("text", {{
            x: node.x,
            y: node.y + 30,
            class: "label",
          }}, node.label || node.id);
          if (focusIds && !focusIds.has(node.id)) {{
            label.classList.add("dimmed");
          }}

          group.appendChild(circle);
          group.appendChild(label);
          svg.appendChild(group);
        }});
      }}

      svg.addEventListener("mousemove", (evt) => {{
        if (!draggingId) {{
          return;
        }}
        const node = nodeById.get(draggingId);
        if (!node) {{
          return;
        }}
        const pt = svgPoint(evt);
        node.x = Math.max(24, Math.min(width - 24, pt.x));
        node.y = Math.max(24, Math.min(height - 24, pt.y));
        render();
      }});

      window.addEventListener("mouseup", () => {{
        draggingId = null;
      }});

      search.addEventListener("input", () => {{
        searchTerm = search.value;
        render();
      }});

      reset.addEventListener("click", () => {{
        searchTerm = "";
        selectedId = null;
        search.value = "";
        setDetails(null);
        render();
      }});

      renderLegend();
      setDetails(null);
      render();
    }})();
  </script>
</body>
</html>
"""


def _extract_graph_data_object(raw: str) -> str:
    """Extract the object assigned to ``GRAPH_DATA`` from JS or HTML text."""
    marker = "GRAPH_DATA"
    marker_pos = raw.find(marker)
    if marker_pos == -1:
        start = raw.find("{")
        end = raw.rfind("}")
        if start == -1 or end == -1 or start >= end:
            raise ValueError("Could not locate graph data object")
        return raw[start:end + 1]

    start = raw.find("{", marker_pos)
    if start == -1:
        raise ValueError("Could not locate start of GRAPH_DATA object")

    depth = 0
    in_string = False
    string_char = ""
    escape = False

    for idx in range(start, len(raw)):
        ch = raw[idx]
        if in_string:
            if escape:
                escape = False
            elif ch == "\\":
                escape = True
            elif ch == string_char:
                in_string = False
        else:
            if ch in ('"', "'", "`"):
                in_string = True
                string_char = ch
            elif ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    return raw[start:idx + 1]

    raise ValueError("Could not find the end of the GRAPH_DATA object")



def parse_graph_data_text(raw: str) -> KnowledgeGraph:
    """Parse JS/HTML text that contains a ``GRAPH_DATA`` object assignment.

    Uses a JSON5 parser to handle unquoted keys, single-line comments,
    and trailing commas that are valid in JavaScript but not in JSON.
    """
    js_obj = _extract_graph_data_object(raw)
    data = pyjson5.loads(js_obj)
    return KnowledgeGraph(**data)


def parse_graph_data_js(path: str | Path) -> KnowledgeGraph:
    """Parse a ``graph-data.js`` file."""
    raw = Path(path).read_text(encoding="utf-8")
    return parse_graph_data_text(raw)


def parse_graph_html(path: str | Path) -> KnowledgeGraph:
    """Parse a standalone HTML graph export with embedded ``GRAPH_DATA``."""
    raw = Path(path).read_text(encoding="utf-8")
    return parse_graph_data_text(raw)


def parse_graph_artifact(path: str | Path) -> KnowledgeGraph:
    """Parse JSON, JS, or HTML graph artifacts."""
    path = Path(path)
    raw = path.read_text(encoding="utf-8")
    if path.suffix.lower() == ".json":
        return KnowledgeGraph(**json.loads(raw))
    return parse_graph_data_text(raw)


def _clean_for_js(obj: Any) -> Any:
    """Strip None values so the JS side sees clean objects."""
    if isinstance(obj, dict):
        return {k: _clean_for_js(v) for k, v in obj.items() if v is not None}
    if isinstance(obj, list):
        return [_clean_for_js(i) for i in obj]
    return obj


def _export_engine(
    engine: GraphEngine,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> GraphEngine:
    normalized = (
        visibility
        if isinstance(visibility, GraphVisibility)
        else normalize_graph_visibility(visibility)
    )
    if normalized == GraphVisibility.ALL:
        return engine
    return filtered_engine(engine, normalized)


def _require_plotly():
    try:
        import plotly.graph_objects as go
    except ImportError as exc:  # pragma: no cover - exercised only when dependency missing
        raise ImportError(
            "Plotly export requires the `plotly` package. "
            "Install `ExposoGraph[notebook]` or `pip install plotly`."
        ) from exc
    return go


def _plotly_hover_lines(data: dict[str, Any], fields: list[tuple[str, str]]) -> str:
    lines = []
    for label, key in fields:
        value = data.get(key)
        if value not in (None, ""):
            lines.append(f"<b>{label}:</b> {value}")
    return "<br>".join(lines)


def _plotly_node_hover(node: dict[str, Any]) -> str:
    title = node.get("label") or node.get("id") or "Node"
    detail = node.get("detail") or "No detail available."
    meta = _plotly_hover_lines(
        node,
        [
            ("ID", "id"),
            ("Type", "type"),
            ("Origin", "origin"),
            ("Match", "match_status"),
            ("Canonical", "canonical_label"),
            ("Canonical ID", "canonical_id"),
            ("Group", "group"),
            ("IARC", "iarc"),
            ("Phase", "phase"),
            ("Role", "role"),
            ("Reactivity", "reactivity"),
            ("Tissue", "tissue"),
            ("Variant", "variant"),
            ("Phenotype", "phenotype"),
            ("Source", "source_db"),
            ("Evidence", "evidence"),
            ("PMID", "pmid"),
        ],
    )
    return f"<b>{title}</b><br>{detail}" + (f"<br><br>{meta}" if meta else "")


def _plotly_edge_hover(edge: dict[str, Any]) -> str:
    title = f"{edge.get('type', 'EDGE')}: {edge.get('source')} -> {edge.get('target')}"
    meta = _plotly_hover_lines(
        edge,
        [
            ("Label", "label"),
            ("Origin", "origin"),
            ("Match", "match_status"),
            ("Canonical", "canonical_predicate"),
            ("Custom", "custom_predicate"),
            ("Source", "source_db"),
            ("Evidence", "evidence"),
            ("PMID", "pmid"),
        ],
    )
    return title + (f"<br><br>{meta}" if meta else "")


def _plotly_positions(engine: GraphEngine) -> dict[str, tuple[float, float]]:
    import networkx as nx

    graph = engine.G
    if graph.number_of_nodes() == 0:
        return {}
    if graph.number_of_nodes() == 1:
        node_id = next(iter(graph.nodes()))
        return {str(node_id): (0.0, 0.0)}

    layout_graph = nx.Graph()
    layout_graph.add_nodes_from(graph.nodes())
    layout_graph.add_edges_from((u, v) for u, v in graph.edges())
    spring_k = 1.6 / math.sqrt(max(layout_graph.number_of_nodes(), 1))
    pos = nx.spring_layout(layout_graph, seed=42, k=spring_k, iterations=200)
    return {
        str(node_id): (float(coords[0]), float(coords[1]))
        for node_id, coords in pos.items()
    }


def _graph_data_script(
    engine: GraphEngine,
    *,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> str:
    data = _clean_for_js(_export_engine(engine, visibility).to_dict())
    return f"const GRAPH_DATA = {json.dumps(data, indent=2)};"


def _default_template_candidates() -> list[Path]:
    repo_root = Path(__file__).resolve().parent.parent
    return [
        repo_root / "references" / "knowledge-graph" / "index.html",
    ]


def _package_template_text() -> str | None:
    """Load the checked-in D3 HTML template from package resources.

    This makes exports work after installation without relying on a repo-local
    `references/knowledge-graph/` folder.
    """
    try:
        return (resource_files(__package__) / "temp_kg" / "index.html").read_text(encoding="utf-8")
    except Exception:
        return None


def _load_viewer_template(template_path: str | Path | None = None) -> str:
    candidates: list[Path] = []
    if template_path is not None:
        template_path = Path(template_path)
        if template_path.is_dir():
            candidates.append(template_path / "index.html")
        else:
            candidates.append(template_path)
    candidates.extend(_default_template_candidates())

    for candidate in candidates:
        if candidate.exists():
            return candidate.read_text(encoding="utf-8")

    pkg_template = _package_template_text()
    if pkg_template is not None:
        return pkg_template

    return _builtin_viewer_template()


def to_graph_data_js(
    engine: GraphEngine,
    path: str | Path,
    *,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
    """Write a ``graph-data.js`` file consumable by the D3 viewer."""
    path = Path(path)
    header = (
        "// ═══════════════════════════════════════════════════════════════\n"
        f"// Auto-generated by {APP_NAME} v{APP_VERSION}\n"
        "// ═══════════════════════════════════════════════════════════════\n\n"
    )

    js = header + _graph_data_script(engine, visibility=visibility) + "\n"
    path.write_text(js, encoding="utf-8")
    return path


def to_plotly_figure(
    engine: GraphEngine,
    *,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
    title: str | None = None,
):
    """Build an interactive Plotly figure for the graph."""
    go = _require_plotly()
    export_engine = _export_engine(engine, visibility)
    graph_data = _clean_for_js(export_engine.to_dict())
    nodes = graph_data.get("nodes", [])
    edges = graph_data.get("edges", [])
    positions = _plotly_positions(export_engine)

    edge_groups: dict[str, dict[str, list[Any]]] = defaultdict(
        lambda: {"x": [], "y": [], "mid_x": [], "mid_y": [], "hover": []}
    )
    for edge in edges:
        source = positions.get(edge.get("source", ""))
        target = positions.get(edge.get("target", ""))
        if source is None or target is None:
            continue
        edge_type = edge.get("type", "EDGE")
        bundle = edge_groups[edge_type]
        bundle["x"].extend([source[0], target[0], None])
        bundle["y"].extend([source[1], target[1], None])
        bundle["mid_x"].append((source[0] + target[0]) / 2)
        bundle["mid_y"].append((source[1] + target[1]) / 2)
        bundle["hover"].append(_plotly_edge_hover(edge))

    node_groups: dict[str, dict[str, list[Any]]] = defaultdict(
        lambda: {"x": [], "y": [], "text": [], "hover": [], "ids": []}
    )
    for node in nodes:
        node_id = node.get("id")
        if not node_id or node_id not in positions:
            continue
        node_type = node.get("type", "Node")
        bundle = node_groups[node_type]
        bundle["x"].append(positions[node_id][0])
        bundle["y"].append(positions[node_id][1])
        bundle["text"].append(node.get("label", node_id))
        bundle["hover"].append(_plotly_node_hover(node))
        bundle["ids"].append(node_id)

    figure = go.Figure()

    for edge_type in sorted(edge_groups):
        color = _EDGE_COLORS.get(edge_type, "#8ea4bb")
        bundle = edge_groups[edge_type]
        figure.add_trace(
            go.Scatter(
                x=bundle["x"],
                y=bundle["y"],
                mode="lines",
                line={"color": color, "width": 2},
                opacity=0.38 if edge_type == "PATHWAY" else 0.62,
                hoverinfo="skip",
                name=f"{edge_type} edge",
                legendgroup=f"edge-{edge_type}",
            )
        )
        if bundle["mid_x"]:
            figure.add_trace(
                go.Scatter(
                    x=bundle["mid_x"],
                    y=bundle["mid_y"],
                    mode="markers",
                    marker={"size": 10, "color": color, "opacity": 0.01},
                    hovertemplate="%{text}<extra></extra>",
                    text=bundle["hover"],
                    showlegend=False,
                    legendgroup=f"edge-{edge_type}",
                )
            )

    for node_type in sorted(node_groups):
        color = _NODE_COLORS.get(node_type, "#76c3ff")
        symbol = _NODE_SYMBOLS.get(node_type, "circle")
        size = _NODE_SIZES.get(node_type, 18)
        bundle = node_groups[node_type]
        figure.add_trace(
            go.Scatter(
                x=bundle["x"],
                y=bundle["y"],
                mode="markers+text",
                text=bundle["text"],
                textposition="bottom center",
                textfont={"size": 11, "color": "#dfe8f4"},
                hovertemplate="%{hovertext}<extra></extra>",
                hovertext=bundle["hover"],
                marker={
                    "size": size,
                    "color": color,
                    "symbol": symbol,
                    "line": {"color": "#08111f", "width": 1.5},
                    "opacity": 0.92,
                },
                name=node_type,
                legendgroup=f"node-{node_type}",
                customdata=bundle["ids"],
            )
        )

    figure.update_layout(
        title=title or f"{APP_NAME} Knowledge Graph",
        paper_bgcolor="#08111f",
        plot_bgcolor="#08111f",
        font={"family": "Inter, system-ui, sans-serif", "color": "#e6edf7"},
        hoverlabel={"bgcolor": "#0f1726", "font": {"color": "#e6edf7"}},
        margin={"l": 24, "r": 24, "t": 68, "b": 24},
        showlegend=True,
        legend={
            "orientation": "h",
            "yanchor": "bottom",
            "y": 1.02,
            "xanchor": "left",
            "x": 0,
            "bgcolor": "rgba(8, 17, 31, 0.7)",
            "bordercolor": "rgba(142, 176, 205, 0.22)",
            "borderwidth": 1,
            "font": {"size": 11},
        },
        xaxis={"visible": False},
        yaxis={"visible": False, "scaleanchor": "x", "scaleratio": 1},
        dragmode="pan",
        annotations=[
            {
                "text": f"{len(nodes)} nodes · {len(edges)} edges",
                "xref": "paper",
                "yref": "paper",
                "x": 1,
                "y": 1.06,
                "showarrow": False,
                "font": {"size": 12, "color": "#95a6bc"},
                "xanchor": "right",
            }
        ],
    )
    figure.update_xaxes(fixedrange=False)
    figure.update_yaxes(fixedrange=False)
    return figure


def to_plotly_html_string(
    engine: GraphEngine,
    *,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
    title: str | None = None,
    include_plotlyjs: bool | str = True,
) -> str:
    """Render a standalone Plotly HTML document."""
    figure = to_plotly_figure(engine, visibility=visibility, title=title)
    return figure.to_html(
        full_html=True,
        include_plotlyjs=include_plotlyjs,
        config={
            "displaylogo": False,
            "responsive": True,
            "scrollZoom": True,
        },
    )


def to_plotly_html(
    engine: GraphEngine,
    path: str | Path,
    *,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
    title: str | None = None,
    include_plotlyjs: bool | str = True,
) -> Path:
    """Write a standalone Plotly HTML file."""
    path = Path(path)
    html = to_plotly_html_string(
        engine,
        visibility=visibility,
        title=title,
        include_plotlyjs=include_plotlyjs,
    )
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(html, encoding="utf-8")
    return path


def to_interactive_html_string(
    engine: GraphEngine,
    *,
    template_path: str | Path | None = None,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> str:
    """Render a self-contained interactive HTML document."""
    template = _load_viewer_template(template_path)
    data_script = _graph_data_script(engine, visibility=visibility)
    script_tag = f"<script>\n{data_script}\n</script>"
    external_tag = '<script src="./graph-data.js"></script>'

    if external_tag in template:
        return template.replace(external_tag, script_tag, 1)

    head_close = "</head>"
    if head_close in template:
        return template.replace(head_close, f"{script_tag}\n{head_close}", 1)

    return f"{script_tag}\n{template}"


def to_interactive_html(
    engine: GraphEngine,
    path: str | Path,
    *,
    template_path: str | Path | None = None,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
    """Write a standalone interactive HTML file with embedded graph data."""
    path = Path(path)
    html = to_interactive_html_string(
        engine,
        template_path=template_path,
        visibility=visibility,
    )
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(html, encoding="utf-8")
    return path


def ensure_viewer_bundle(
    output_dir: str | Path,
    *,
    template_dir: str | Path | None = None,
) -> Path:
    """Ensure *output_dir* is a usable D3 viewer bundle directory.

    When *template_dir* contains an ``index.html`` file, it is copied into the
    output directory if missing so the generated ``graph-data.js`` has a viewer
    to pair with.
    """
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    if template_dir is not None:
        template_dir = Path(template_dir)
        template_index = template_dir / "index.html"
        target_index = output_dir / "index.html"
        if template_index.exists() and template_index != target_index and not target_index.exists():
            shutil.copy2(template_index, target_index)

    target_index = output_dir / "index.html"
    if not target_index.exists():
        # If a repo-local template isn't present, use the package-embedded
        # one so viewer bundles still work after installation.
        target_index.write_text(_load_viewer_template(template_dir), encoding="utf-8")

    return output_dir


def export_viewer_bundle(
    engine: GraphEngine,
    output_dir: str | Path,
    *,
    template_dir: str | Path | None = None,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
    """Write a complete viewer bundle directory.

    The resulting folder contains ``graph-data.js`` and, when a template is
    available, ``index.html``.
    """
    output_dir = ensure_viewer_bundle(output_dir, template_dir=template_dir)
    to_graph_data_js(engine, output_dir / "graph-data.js", visibility=visibility)
    return output_dir


def to_json(
    engine: GraphEngine,
    path: str | Path,
    *,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
    """Write a plain JSON export."""
    path = Path(path)
    data = _clean_for_js(_export_engine(engine, visibility).to_dict())
    path.write_text(json.dumps(data, indent=2), encoding="utf-8")
    return path


def to_gexf(
    engine: GraphEngine,
    path: str | Path,
    *,
    visibility: GraphVisibility | str = GraphVisibility.ALL,
) -> Path:
    """Write GEXF (Gephi) format.

    Non-scalar node/edge attributes (lists, dicts) are serialized to
    JSON strings since GEXF only supports primitive attribute types.
    """
    import networkx as nx

    path = Path(path)
    # Deep-copy graph and flatten complex attributes for GEXF compatibility
    G = _export_engine(engine, visibility).G.copy()
    _SCALAR = (str, int, float, bool)
    for _node_id, data in G.nodes(data=True):
        for key, val in list(data.items()):
            if val is not None and not isinstance(val, _SCALAR):
                data[key] = json.dumps(val, default=str)
    for _u, _v, _k, data in G.edges(keys=True, data=True):
        for key, val in list(data.items()):
            if val is not None and not isinstance(val, _SCALAR):
                data[key] = json.dumps(val, default=str)
    nx.write_gexf(G, str(path))
    return path
