#!/usr/bin/env python3
"""Render a static faculty-facing alignment preview from graph.json."""

from __future__ import annotations

import argparse
import html
import json
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
VENDOR_D3_PATH = PLUGIN_ROOT / "templates" / "vendor" / "d3.v7.min.js"

CONFIDENCE_LABELS = {
    "EXTRACTED": "Explicit evidence",
    "INFERRED": "Likely alignment",
    "AMBIGUOUS": "Needs review",
}

# Phase 3 — Graphify-style visualization node color palette.
# Colors are paired with the existing confidence badge palette so the graph
# view reads consistently with the table view below it.
NODE_TYPE_COLORS = {
    "clo": "#8c1d40",            # ASU maroon — outcomes
    "mlo": "#ffc627",            # ASU gold — module objectives
    "page": "#3b82f6",           # blue — learning material
    "video": "#3b82f6",
    "reading": "#3b82f6",
    "assignment": "#16a34a",     # green — assessment
    "discussion": "#16a34a",
    "quiz": "#16a34a",
    "quiz_question": "#9ca3af",  # gray — assessment parts
    "rubric_criterion": "#9ca3af",
}


def _load_vendor_d3() -> str:
    """Inline d3.js v7 from `templates/vendor/d3.v7.min.js`.

    Vendor-inlining keeps the rendered HTML self-contained — no CDN, no
    network dependency on first open. Fails fast (FileNotFoundError) when
    the vendor file is absent so the render does not silently ship a
    partial HTML with a missing graph.
    """
    if not VENDOR_D3_PATH.exists():
        raise FileNotFoundError(
            f"Vendor d3.js not found at {VENDOR_D3_PATH}. "
            "Run `curl -sSL https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js "
            f"-o {VENDOR_D3_PATH}` and update templates/vendor/SHA256SUMS.txt."
        )
    return VENDOR_D3_PATH.read_text(encoding="utf-8")

NODE_TYPE_LABELS = {
    "clo": "CLO",
    "mlo": "MLO",
    "page": "Page",
    "video": "Video",
    "reading": "Reading",
    "assignment": "Assignment",
    "discussion": "Discussion",
    "quiz": "Quiz",
    "quiz_question": "Quiz question",
    "rubric_criterion": "Rubric criterion",
}


def _esc(value: Any) -> str:
    return html.escape(str(value or ""), quote=True)


def _truncate(value: Any, length: int = 220) -> str:
    text = " ".join(str(value or "").split())
    if len(text) <= length:
        return text
    return text[: length - 1].rstrip() + "..."


def _node_module(node: dict[str, Any]) -> str:
    module = (node.get("metadata") or {}).get("module_number")
    return str(module or "")


def _node_label(node: dict[str, Any]) -> str:
    label = str(node.get("label") or node.get("id") or "")
    node_type = NODE_TYPE_LABELS.get(str(node.get("type")), str(node.get("type") or "Item"))
    if label:
        return f"{node_type}: {label}"
    return node_type


def _confidence_label(value: Any) -> str:
    return CONFIDENCE_LABELS.get(str(value or ""), str(value or "Unknown"))


def _index_graph(graph: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], dict[str, list[dict[str, Any]]], dict[str, list[dict[str, Any]]]]:
    nodes = {node["id"]: node for node in graph.get("nodes", []) if node.get("id")}
    outgoing: dict[str, list[dict[str, Any]]] = defaultdict(list)
    incoming: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for edge in graph.get("edges", []):
        if not edge.get("source") or not edge.get("target"):
            continue
        outgoing[edge["source"]].append(edge)
        incoming[edge["target"]].append(edge)
    return nodes, outgoing, incoming


def _mapped_targets(
    node_id: str,
    nodes: dict[str, dict[str, Any]],
    outgoing: dict[str, list[dict[str, Any]]],
    edge_types: set[str],
    target_types: set[str],
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
    mapped = []
    for edge in outgoing.get(node_id, []):
        target = nodes.get(edge.get("target"))
        if edge.get("type") in edge_types and target and target.get("type") in target_types:
            mapped.append((edge, target))
    return mapped


def _summary_counts(graph: dict[str, Any]) -> dict[str, int]:
    nodes = graph.get("nodes", [])
    gaps = graph.get("gaps", {})
    return {
        "clos": sum(1 for node in nodes if node.get("type") == "clo"),
        "mlos": sum(1 for node in nodes if node.get("type") == "mlo"),
        "materials": sum(1 for node in nodes if node.get("type") in {"page", "video", "reading"}),
        "assessments": sum(1 for node in nodes if node.get("type") in {"assignment", "discussion", "quiz"}),
        "orphaned_mlos": len(gaps.get("mlo_without_assessment", [])),
        "ambiguous": len(gaps.get("ambiguous_edges", [])),
    }


def _summary_html(graph: dict[str, Any]) -> str:
    counts = _summary_counts(graph)
    cards = [
        ("CLOs", counts["clos"]),
        ("MLOs", counts["mlos"]),
        ("Materials", counts["materials"]),
        ("Assessments", counts["assessments"]),
        ("MLOs needing assessment evidence", counts["orphaned_mlos"]),
        ("Relationships needing review", counts["ambiguous"]),
    ]
    return "\n".join(
        f'<div class="summary-card"><strong>{count}</strong><span>{_esc(label)}</span></div>'
        for label, count in cards
    )


def _summary_warning_html(graph: dict[str, Any]) -> str:
    counts = _summary_counts(graph)
    messages = []
    if counts["clos"] == 0:
        messages.append("No CLO nodes were found in this graph.")
    if counts["mlos"] == 0:
        messages.append("No MLO nodes were found in this graph.")
    if not messages:
        return ""
    items = "".join(f"<li>{_esc(message)}</li>" for message in messages)
    return f'<div class="warning-callout"><strong>Needs setup review</strong><ul>{items}</ul></div>'


def _filter_options(nodes: dict[str, dict[str, Any]], graph: dict[str, Any]) -> str:
    modules = sorted({int(module) for node in nodes.values() if (module := _node_module(node)).isdigit()})
    clos = sorted((node for node in nodes.values() if node.get("type") == "clo"), key=lambda item: item.get("label", ""))
    mlos = sorted((node for node in nodes.values() if node.get("type") == "mlo"), key=lambda item: item.get("label", ""))
    node_types = sorted({str(node.get("type")) for node in nodes.values() if node.get("type")})
    confidences = [value for value in ("EXTRACTED", "INFERRED", "AMBIGUOUS") if value in CONFIDENCE_LABELS]

    def options(values: list[tuple[str, str]] | list[str]) -> str:
        rows = ['<option value="">All</option>']
        for item in values:
            if isinstance(item, tuple):
                value, label = item
            else:
                value = label = item
            rows.append(f'<option value="{_esc(value)}">{_esc(label)}</option>')
        return "".join(rows)

    return f"""
<section class="filters" aria-label="Preview filters">
  <label>Module<select id="filter-module">{options([(str(item), f"Module {item}") for item in modules])}</select></label>
  <label>CLO<select id="filter-clo">{options([(node["id"], node.get("label", node["id"])) for node in clos])}</select></label>
  <label>MLO<select id="filter-mlo">{options([(node["id"], node.get("label", node["id"])) for node in mlos])}</select></label>
  <label>Node type<select id="filter-type">{options([(item, NODE_TYPE_LABELS.get(item, item)) for item in node_types])}</select></label>
  <label>Confidence<select id="filter-confidence">{options([(item, _confidence_label(item)) for item in confidences])}</select></label>
</section>
"""


def _path_rows(nodes: dict[str, dict[str, Any]], outgoing: dict[str, list[dict[str, Any]]]) -> str:
    rows = []
    clos = sorted((node for node in nodes.values() if node.get("type") == "clo"), key=lambda item: item.get("label", ""))
    all_edges = [edge for edge_list in outgoing.values() for edge in edge_list]
    for clo in clos:
        mlo_edges = [
            edge for edge in all_edges
            if edge.get("type") == "aligns_to" and edge.get("target") == clo["id"]
        ]
        for mlo_edge in mlo_edges:
            mlo = nodes.get(mlo_edge.get("source"))
            if not mlo or mlo.get("type") != "mlo":
                continue
            assessment_edges = [
                edge for edge in all_edges
                if edge.get("type") in {"assesses", "aligns_to"} and edge.get("target") == mlo["id"]
            ]
            material_edges = [
                edge for edge in all_edges
                if edge.get("type") == "supports" and edge.get("target") == mlo["id"]
            ]
            connected = assessment_edges or material_edges or [mlo_edge]
            for edge in connected:
                source_node = nodes.get(edge.get("source"), mlo)
                if source_node["id"] == mlo["id"]:
                    path = f"{clo.get('label')} -> {mlo.get('label')}"
                    path_nodes = [clo, mlo]
                else:
                    path = f"{clo.get('label')} -> {mlo.get('label')} -> {source_node.get('label')}"
                    path_nodes = [clo, mlo, source_node]
                # Inline LO + item text snippets so reviewers don't need to memorize labels.
                snippet_parts = [
                    f"<strong>{_esc(n.get('label'))}</strong> &mdash; {_esc(_truncate(n.get('text'), 110))}"
                    for n in path_nodes if (n.get('text') or '').strip()
                ]
                path_snippets = (
                    f"<div style='font-size:11px;color:var(--muted);margin-top:4px;line-height:1.4;'>"
                    f"{' &nbsp;&middot;&nbsp; '.join(snippet_parts)}</div>"
                ) if snippet_parts else ""
                rows.append(
                    "<tr class=\"filter-row\" "
                    f"data-module=\"{_esc(_node_module(source_node) or _node_module(mlo))}\" "
                    f"data-clo=\"{_esc(clo['id'])}\" "
                    f"data-mlo=\"{_esc(mlo['id'])}\" "
                    f"data-type=\"{_esc(source_node.get('type'))}\" "
                    f"data-confidence=\"{_esc(edge.get('confidence'))}\">"
                    f"<td><div>{_esc(path)}</div>{path_snippets}</td>"
                    f"<td><span class=\"badge confidence-{_esc(str(edge.get('confidence', '')).lower())}\">{_esc(_confidence_label(edge.get('confidence')))}</span></td>"
                    f"<td>{_esc(_truncate(edge.get('evidence')))}</td>"
                    "</tr>"
                )
    if not rows:
        rows.append('<tr><td colspan="3">No alignment paths found in the graph.</td></tr>')
    return "".join(rows)


def _gap_rows(graph: dict[str, Any], nodes: dict[str, dict[str, Any]]) -> str:
    labels = {
        "mlo_without_assessment": "MLO has no assessment evidence",
        "mlo_without_material": "MLO has no material evidence",
        "assessment_without_mlo": "Assessment has no MLO evidence",
        "quiz_question_without_mlo": "Quiz question needs MLO review",
        "rubric_criterion_without_mlo": "Rubric criterion needs MLO review",
        "ambiguous_edges": "Relationship needs review",
    }
    edge_lookup = {edge["id"]: edge for edge in graph.get("edges", []) if edge.get("id")}
    rows = []
    for key, label in labels.items():
        for item_id in graph.get("gaps", {}).get(key, []):
            node = nodes.get(item_id)
            edge = edge_lookup.get(item_id)
            if node:
                item_label = _node_label(node)
                evidence = _truncate(node.get("text"))
                module = _node_module(node)
            elif edge:
                item_label = f"{edge.get('type', 'relationship')} relationship"
                evidence = _truncate(edge.get("evidence"))
                module = ""
            else:
                item_label = item_id
                evidence = ""
                module = ""
            rows.append(
                "<tr class=\"filter-row\" "
                f"data-module=\"{_esc(module)}\" data-clo=\"\" data-mlo=\"{_esc(item_id if key.startswith('mlo_') else '')}\" "
                f"data-type=\"{_esc((node or {}).get('type', ''))}\" "
                f"data-confidence=\"{_esc((edge or {}).get('confidence', ''))}\">"
                f"<td>{_esc(label)}</td><td>{_esc(item_label)}</td><td>{_esc(evidence)}</td>"
                "</tr>"
            )
    if not rows:
        rows.append('<tr><td colspan="3">No possible gaps were found in the current graph.</td></tr>')
    return "".join(rows)


def _assessment_cards(nodes: dict[str, dict[str, Any]], outgoing: dict[str, list[dict[str, Any]]]) -> str:
    cards = []
    assessment_types = {"assignment", "discussion", "quiz"}
    assessments = sorted(
        (node for node in nodes.values() if node.get("type") in assessment_types),
        key=lambda item: (_node_module(item), item.get("label", "")),
    )
    for node in assessments:
        mapped_mlos = _mapped_targets(node["id"], nodes, outgoing, {"assesses", "aligns_to"}, {"mlo"})
        mapped_mlo_ids = " ".join(target["id"] for _edge, target in mapped_mlos)
        mapped_confidences = " ".join(edge["confidence"] for edge, _target in mapped_mlos if edge.get("confidence"))
        children = [
            nodes[edge["target"]]
            for edge in outgoing.get(node["id"], [])
            if edge.get("type") == "contains" and edge.get("target") in nodes
        ]
        child_items = "".join(
            f"<li><strong>{_esc(_node_label(child))}</strong>: {_esc(_truncate(child.get('text'), 160))}</li>"
            for child in children
        ) or "<li>No quiz questions or rubric criteria found in the graph.</li>"
        mlo_items = "".join(
            (
                f"<li><span class=\"badge confidence-{_esc(str(edge.get('confidence', '')).lower())}\">"
                f"{_esc(_confidence_label(edge.get('confidence')))}</span> "
                f"<strong>{_esc(target.get('label'))}</strong> &mdash; "
                f"<span style='color:var(--ink);'>{_esc(_truncate(target.get('text'), 160))}</span>"
                f"<div style='font-size:11px;color:var(--muted);margin-top:3px;'>"
                f"Evidence: {_esc(_truncate(edge.get('evidence'), 180))}</div></li>"
            )
            for edge, target in mapped_mlos
        ) or "<li>No explicit MLO mapping found.</li>"
        cards.append(
            "<article class=\"assessment-card filter-row\" "
            f"data-module=\"{_esc(_node_module(node))}\" data-clo=\"\" "
            f"data-mlo=\"{_esc(mapped_mlo_ids)}\" "
            f"data-type=\"{_esc(node.get('type'))}\" data-confidence=\"{_esc(mapped_confidences)}\">"
            f"<h3>{_esc(_node_label(node))}</h3>"
            f"<p>{_esc(_truncate(node.get('text'), 260))}</p>"
            "<h4>Mapped MLO evidence</h4>"
            f"<ul>{mlo_items}</ul>"
            "<h4>Assessment parts</h4>"
            f"<ul>{child_items}</ul>"
            "</article>"
        )
    if not cards:
        return "<p>No assessment nodes found in the graph.</p>"
    return "".join(cards)


def _faculty_notes(graph: dict[str, Any]) -> str:
    conflict_policy = graph.get("conflict_policy") or (
        "course-config represents intended design. Future Canvas evidence should be shown separately when it differs."
    )
    return f"""
<ul>
  <li>This preview is read-only and does not change Canvas content.</li>
  <li>Use "Needs review" as a prompt for faculty judgment, not as a defect label.</li>
  <li>Inferred relationships should be checked before using them to revise assignments, discussions, or quiz questions.</li>
  <li>{_esc(conflict_policy)}</li>
</ul>
"""


def _graph_payload(graph: dict[str, Any]) -> dict[str, Any]:
    """Build the JSON payload the d3 force simulation consumes.

    Strips heavy `text` fields so the embedded payload stays compact even
    for large courses (LAW 519 ~ 322 nodes, 444 edges). The full node text
    is still available in the table views below the graph.
    """
    nodes_out = []
    for node in graph.get("nodes", []):
        nodes_out.append({
            "id": node.get("id"),
            "type": node.get("type"),
            "label": node.get("label") or node.get("id"),
            "module": (node.get("metadata") or {}).get("module_number"),
            "source": node.get("source"),
            "canvas_url": node.get("canvas_url", ""),
            "text": _truncate(node.get("text"), 140),
        })
    edges_out = []
    for edge in graph.get("edges", []):
        if not edge.get("source") or not edge.get("target"):
            continue
        edges_out.append({
            "source": edge.get("source"),
            "target": edge.get("target"),
            "type": edge.get("type"),
            "confidence": edge.get("confidence"),
            "evidence": _truncate(edge.get("evidence"), 200),
        })
    return {"nodes": nodes_out, "edges": edges_out}


def _graph_section_html() -> str:
    return """
<section id=\"graph-section\" aria-label=\"Alignment graph view\">
  <div style=\"display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px;\">
    <h2 style=\"margin:0;\">Alignment Graph</h2>
    <div style=\"display:flex; gap:8px; align-items:center;\">
      <button id=\"layout-toggle\" type=\"button\" aria-label=\"Toggle graph layout\">Switch to hierarchical layout</button>
      <button id=\"reset-graph-filter\" type=\"button\" aria-label=\"Clear node filter\">Clear selection</button>
    </div>
  </div>
  <div id=\"graph-legend\" style=\"display:flex; flex-wrap:wrap; gap:14px; font-size:12px; color:var(--muted); margin-bottom:6px;\">
    <span><span style=\"display:inline-block;width:10px;height:10px;border-radius:50%;background:#8c1d40;margin-right:4px;\"></span>CLO</span>
    <span><span style=\"display:inline-block;width:10px;height:10px;border-radius:50%;background:#ffc627;margin-right:4px;\"></span>MLO</span>
    <span><span style=\"display:inline-block;width:10px;height:10px;border-radius:50%;background:#3b82f6;margin-right:4px;\"></span>Material</span>
    <span><span style=\"display:inline-block;width:10px;height:10px;border-radius:50%;background:#16a34a;margin-right:4px;\"></span>Assessment</span>
    <span><span style=\"display:inline-block;width:10px;height:10px;border-radius:50%;background:#9ca3af;margin-right:4px;\"></span>Question/Criterion</span>
    <span style=\"margin-left:14px;\"><span style=\"display:inline-block;width:24px;height:0;border-top:2px solid #333;vertical-align:middle;margin-right:4px;\"></span>Explicit evidence</span>
    <span><span style=\"display:inline-block;width:24px;height:0;border-top:2px dashed #555;vertical-align:middle;margin-right:4px;\"></span>Likely alignment</span>
    <span><span style=\"display:inline-block;width:24px;height:0;border-top:2px dotted #b91c1c;vertical-align:middle;margin-right:4px;\"></span>Needs review</span>
  </div>
  <div id=\"graph-canvas-wrap\" style=\"position:relative;\">
    <svg id=\"alignment-graph\" role=\"img\" aria-label=\"Force-directed alignment graph\" width=\"100%\" height=\"560\" style=\"border:1px solid var(--line); background:var(--soft); border-radius:6px; display:block;\"></svg>
    <div id=\"graph-pinned-info\" style=\"position:absolute; top:12px; left:12px; background:#fff; border:1px solid var(--line); padding:10px 12px; border-radius:6px; font-size:12px; max-width:360px; box-shadow:0 4px 14px rgba(0,0,0,0.10); display:none; z-index:5; line-height:1.45;\"></div>
  </div>
  <div id=\"graph-tooltip\" style=\"position:absolute; pointer-events:none; background:#fff; border:1px solid var(--line); padding:8px 10px; border-radius:4px; font-size:12px; max-width:320px; box-shadow:0 4px 12px rgba(0,0,0,0.12); display:none; z-index:1000;\"></div>
  <p style=\"font-size:12px; color:var(--muted); margin-top:8px;\">Click a node to filter the tables below to items connected to it. Hover for full text + evidence. Tables remain available for accessibility — the graph is a supplementary view.</p>
</section>
"""


def render_alignment_preview(graph: dict[str, Any]) -> str:
    nodes, outgoing, _incoming = _index_graph(graph)
    title = f"Alignment Preview - {graph.get('course_title') or graph.get('course_id') or 'Course'}"
    d3_script = _load_vendor_d3()
    graph_payload_json = json.dumps(_graph_payload(graph), ensure_ascii=False)
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{_esc(title)}</title>
  <style>
    :root {{
      --asu-maroon: #8c1d40;
      --asu-gold: #ffc627;
      --ink: #1f2933;
      --muted: #5d6b7a;
      --line: #d8dee6;
      --soft: #f6f8fa;
      --review: #fff8df;
      --good: #e8f5e9;
      --likely: #eaf3ff;
    }}
    body {{ margin: 0; font-family: Arial, sans-serif; color: var(--ink); background: #fff; line-height: 1.45; }}
    header {{ background: var(--asu-maroon); color: #fff; padding: 24px 32px; border-bottom: 6px solid var(--asu-gold); }}
    header h1 {{ margin: 0 0 6px; font-size: 30px; }}
    header p {{ margin: 0; color: #f7e7ed; }}
    main {{ max-width: 1180px; margin: 0 auto; padding: 28px 24px 48px; }}
    section {{ margin: 28px 0; }}
    h2 {{ color: var(--asu-maroon); margin: 0 0 12px; }}
    h3 {{ margin-bottom: 8px; }}
    .summary-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }}
    .summary-card {{ border: 1px solid var(--line); background: var(--soft); padding: 14px; border-radius: 6px; }}
    .summary-card strong {{ display: block; font-size: 28px; color: var(--asu-maroon); }}
    .summary-card span {{ color: var(--muted); font-size: 13px; }}
    .warning-callout {{ margin-top: 14px; padding: 12px 14px; border-left: 4px solid var(--asu-gold); background: var(--review); }}
    .warning-callout ul {{ margin: 6px 0 0 18px; padding: 0; }}
    .filters {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; padding: 14px; border: 1px solid var(--line); background: var(--soft); border-radius: 6px; }}
    label {{ font-weight: 700; font-size: 13px; color: var(--muted); }}
    select {{ display: block; width: 100%; margin-top: 5px; padding: 8px; border: 1px solid var(--line); border-radius: 4px; background: #fff; color: var(--ink); }}
    table {{ width: 100%; border-collapse: collapse; margin-top: 10px; }}
    th, td {{ border: 1px solid var(--line); padding: 9px; vertical-align: top; text-align: left; }}
    th {{ background: var(--soft); }}
    .badge {{ display: inline-block; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 700; }}
    .confidence-extracted {{ background: var(--good); color: #1b5e20; }}
    .confidence-inferred {{ background: var(--likely); color: #0d47a1; }}
    .confidence-ambiguous {{ background: var(--review); color: #7a4d00; }}
    .assessment-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); gap: 14px; }}
    .assessment-card {{ border: 1px solid var(--line); border-radius: 6px; padding: 14px; background: #fff; }}
    .assessment-card h3 {{ color: var(--asu-maroon); margin-top: 0; }}
    .assessment-card h4 {{ margin: 14px 0 6px; }}
    .empty-state {{ padding: 12px; border: 1px dashed var(--line); color: var(--muted); }}
    .hidden-by-filter {{ display: none; }}
    .dimmed-by-graph-filter {{ display: none; }}
    #alignment-graph {{ width: 100%; cursor: grab; }}
    #alignment-graph:active {{ cursor: grabbing; }}
    #alignment-graph .node {{ cursor: pointer; }}
    #alignment-graph .node.selected circle {{ stroke: #111; stroke-width: 3px; }}
    #layout-toggle, #reset-graph-filter {{ font: inherit; padding: 6px 12px; border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 4px; cursor: pointer; }}
    #layout-toggle:hover, #reset-graph-filter:hover {{ background: var(--soft); }}
    footer {{ margin-top: 36px; color: var(--muted); font-size: 13px; }}
  </style>
</head>
<body>
  <header>
    <h1>Alignment Preview</h1>
    <p>{_esc(graph.get('course_title') or 'Course')} ({_esc(graph.get('course_id'))})</p>
  </header>
  <main>
    <section>
      <h2>Course Summary</h2>
      <div class="summary-grid">{_summary_html(graph)}</div>
      {_summary_warning_html(graph)}
    </section>

    {_graph_section_html()}

    {_filter_options(nodes, graph)}

    <section>
      <h2>Alignment Paths</h2>
      <table>
        <thead><tr><th>Path</th><th>Confidence</th><th>Evidence</th></tr></thead>
        <tbody>{_path_rows(nodes, outgoing)}</tbody>
      </table>
    </section>

    <section>
      <h2>Possible Gaps</h2>
      <table>
        <thead><tr><th>Review area</th><th>Item</th><th>Evidence</th></tr></thead>
        <tbody>{_gap_rows(graph, nodes)}</tbody>
      </table>
    </section>

    <section>
      <h2>Assessment Detail</h2>
      <div class="assessment-grid">{_assessment_cards(nodes, outgoing)}</div>
    </section>

    <section>
      <h2>Faculty Review Notes</h2>
      {_faculty_notes(graph)}
    </section>

    <footer>
      Generated from {_esc(graph.get('source_mode'))} graph data. Schema: {_esc(graph.get('schema_version'))}.
    </footer>
  </main>
  <script>
    const filters = {{
      module: document.getElementById('filter-module'),
      clo: document.getElementById('filter-clo'),
      mlo: document.getElementById('filter-mlo'),
      type: document.getElementById('filter-type'),
      confidence: document.getElementById('filter-confidence')
    }};
    function matches(row, key, value) {{
      if (!value) return true;
      return (row.dataset[key] || '').split(/\\s+/).includes(value);
    }}
    function applyFilters() {{
      document.querySelectorAll('.filter-row').forEach(row => {{
        const visible =
          matches(row, 'module', filters.module.value) &&
          matches(row, 'clo', filters.clo.value) &&
          matches(row, 'mlo', filters.mlo.value) &&
          matches(row, 'type', filters.type.value) &&
          matches(row, 'confidence', filters.confidence.value);
        row.classList.toggle('hidden-by-filter', !visible);
      }});
    }}
    Object.values(filters).forEach(select => select.addEventListener('change', applyFilters));
  </script>
  <script id="graph-data" type="application/json">{graph_payload_json}</script>
  <script>{d3_script}</script>
  <script>
  (function() {{
    if (typeof d3 === 'undefined') return;  // vendor failed to load (should not happen with inline)
    const payload = JSON.parse(document.getElementById('graph-data').textContent);
    if (!payload.nodes.length) return;

    const NODE_COLORS = {{
      clo: '#8c1d40', mlo: '#ffc627',
      page: '#3b82f6', video: '#3b82f6', reading: '#3b82f6',
      assignment: '#16a34a', discussion: '#16a34a', quiz: '#16a34a',
      quiz_question: '#9ca3af', rubric_criterion: '#9ca3af',
    }};
    const NODE_SIZE = {{ clo: 11, mlo: 9, assignment: 7, discussion: 7, quiz: 7, page: 5, video: 5, reading: 5, quiz_question: 4, rubric_criterion: 4 }};
    const CONF_STROKE = {{ EXTRACTED: '#333', INFERRED: '#555', AMBIGUOUS: '#b91c1c' }};
    const CONF_DASH = {{ EXTRACTED: null, INFERRED: '6,4', AMBIGUOUS: '2,3' }};

    const svg = d3.select('#alignment-graph');
    const width = svg.node().clientWidth || 1100;
    const height = +svg.attr('height') || 560;
    const tooltip = d3.select('#graph-tooltip');

    const nodes = payload.nodes.map(n => Object.assign({{}}, n));
    const links = payload.edges.map(e => Object.assign({{}}, e));

    const g = svg.append('g');
    svg.call(d3.zoom().scaleExtent([0.2, 4]).on('zoom', evt => g.attr('transform', evt.transform)));

    const link = g.append('g').attr('stroke-opacity', 0.6)
      .selectAll('line').data(links).join('line')
      .attr('stroke', d => CONF_STROKE[d.confidence] || '#888')
      .attr('stroke-width', 1.2)
      .attr('stroke-dasharray', d => CONF_DASH[d.confidence] || null)
      .on('mouseenter', (evt, d) => {{
        tooltip.style('display', 'block')
          .style('left', (evt.pageX + 12) + 'px').style('top', (evt.pageY + 12) + 'px')
          .html('<strong>' + d.type + '</strong> (' + (d.confidence || '') + ')<br>' + (d.evidence || ''));
      }})
      .on('mouseleave', () => tooltip.style('display', 'none'));

    const node = g.append('g').selectAll('g.node').data(nodes).join('g')
      .attr('class', 'node')
      .attr('data-node-type', d => d.type)
      .attr('data-confidence', d => d.confidence || '')
      .call(d3.drag()
        .on('start', (evt, d) => {{ d.fx = d.x; d.fy = d.y; }})
        .on('drag', (evt, d) => {{ if (!evt.active) simulation.alphaTarget(0.3).restart(); d.fx = evt.x; d.fy = evt.y; }})
        .on('end', (evt, d) => {{ if (!evt.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }})
      );
    node.append('circle')
      .attr('r', d => NODE_SIZE[d.type] || 5)
      .attr('fill', d => NODE_COLORS[d.type] || '#888')
      .attr('stroke', '#fff').attr('stroke-width', 1.5);
    node.append('title').text(d => d.label + ': ' + (d.text || ''));

    const pinnedInfo = d3.select('#graph-pinned-info');
    function tooltipHtmlFor(d) {{
      return '<strong>' + d.label + '</strong> (' + d.type + ')<br>' + (d.text || '') + (d.canvas_url ? '<br><a href="' + d.canvas_url + '" target="_blank">Open in Canvas</a>' : '');
    }}
    function pinTooltipForNode(d) {{
      // Pinned info lives INSIDE the graph container (positioned relative
      // to #graph-canvas-wrap) so it stays inside the visualization frame.
      pinnedInfo.style('display', 'block')
        .html(tooltipHtmlFor(d) + '<div style="margin-top:8px;font-size:11px;color:#5d6b7a;">Pinned — click background or the same node again to clear.</div>');
    }}

    let selectedId = null;
    node.on('mouseenter', (evt, d) => {{
      // Hover tooltip floats near cursor; pinned info inside the graph
      // stays separate so it never moves with the mouse.
      tooltip.style('display', 'block')
        .style('left', (evt.pageX + 12) + 'px').style('top', (evt.pageY + 12) + 'px')
        .style('max-width', '320px')
        .html(tooltipHtmlFor(d));
    }}).on('mouseleave', () => {{
      tooltip.style('display', 'none');
    }});

    node.on('click', (evt, d) => {{
      evt.stopPropagation();
      if (selectedId === d.id) {{ clearGraphFilter(); return; }}
      selectedId = d.id;
      const connected = new Set([d.id]);
      links.forEach(l => {{
        const s = (typeof l.source === 'object') ? l.source.id : l.source;
        const t = (typeof l.target === 'object') ? l.target.id : l.target;
        if (s === d.id) connected.add(t);
        if (t === d.id) connected.add(s);
      }});
      node.classed('selected', n => n.id === d.id);
      node.style('opacity', n => connected.has(n.id) ? 1 : 0.2);
      link.style('opacity', l => {{
        const s = (typeof l.source === 'object') ? l.source.id : l.source;
        const t = (typeof l.target === 'object') ? l.target.id : l.target;
        return (s === d.id || t === d.id) ? 1 : 0.05;
      }});
      // Pin the tooltip so node info stays visible while the filter is active.
      pinTooltipForNode(d);
      // Filter tables to rows that reference the selected node id.
      document.querySelectorAll('.filter-row').forEach(row => {{
        const ds = row.dataset;
        const hit = (ds.clo === d.id) || (ds.mlo === d.id) || (ds.type === d.type) || ((ds.mlo || '').split(/\\s+/).includes(d.id));
        row.classList.toggle('dimmed-by-graph-filter', !hit);
      }});
    }});

    function clearGraphFilter() {{
      selectedId = null;
      node.classed('selected', false).style('opacity', 1);
      link.style('opacity', 0.6);
      tooltip.style('display', 'none');
      pinnedInfo.style('display', 'none');
      document.querySelectorAll('.filter-row').forEach(row => row.classList.remove('dimmed-by-graph-filter'));
    }}
    svg.on('click', clearGraphFilter);
    document.getElementById('reset-graph-filter').addEventListener('click', clearGraphFilter);

    const simulation = d3.forceSimulation(nodes)
      .force('link', d3.forceLink(links).id(d => d.id).distance(60).strength(0.4))
      .force('charge', d3.forceManyBody().strength(-120))
      .force('center', d3.forceCenter(width / 2, height / 2))
      .force('collide', d3.forceCollide(12));

    simulation.on('tick', () => {{
      link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
          .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
      node.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
    }});

    // Hierarchical layout toggle: CLO -> MLO -> material/assessment -> part.
    let hierarchical = false;
    const TIER = {{ clo: 0, mlo: 1, page: 2, video: 2, reading: 2, assignment: 2, discussion: 2, quiz: 2, quiz_question: 3, rubric_criterion: 3 }};
    document.getElementById('layout-toggle').addEventListener('click', () => {{
      hierarchical = !hierarchical;
      const btn = document.getElementById('layout-toggle');
      btn.textContent = hierarchical ? 'Switch to force layout' : 'Switch to hierarchical layout';
      if (hierarchical) {{
        const tierY = [80, 200, 360, 500];
        const byTier = [[], [], [], []];
        nodes.forEach(n => byTier[TIER[n.type] != null ? TIER[n.type] : 2].push(n));
        byTier.forEach((arr, ti) => {{
          arr.forEach((n, i) => {{ n.fx = (width / (arr.length + 1)) * (i + 1); n.fy = tierY[ti]; }});
        }});
        simulation.alpha(0.5).restart();
      }} else {{
        nodes.forEach(n => {{ n.fx = null; n.fy = null; }});
        simulation.alpha(0.6).restart();
      }}
    }});
  }})();
  </script>
</body>
</html>
"""


def default_graph_path(course_id: str) -> Path:
    return PLUGIN_ROOT / "courses" / str(course_id) / "alignment" / "graph.json"


def default_output_path(course_id: str) -> Path:
    return PLUGIN_ROOT / "courses" / str(course_id) / "alignment" / "alignment-preview.html"


def load_graph(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def write_preview(graph: dict[str, Any], path: Path) -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(render_alignment_preview(graph), encoding="utf-8")
    return path


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Render static alignment-preview.html from graph.json.")
    parser.add_argument("--course-id", help="Course ID. Defaults graph/output paths under courses/{id}/alignment/.")
    parser.add_argument("--graph", help="Path to graph.json. Defaults to courses/{course-id}/alignment/graph.json.")
    parser.add_argument("--output", help="Path to alignment-preview.html. Defaults to courses/{course-id}/alignment/alignment-preview.html.")
    args = parser.parse_args(argv)

    if not args.graph and not args.course_id:
        parser.error("Provide --course-id or --graph")

    graph_path = Path(args.graph) if args.graph else default_graph_path(str(args.course_id))
    graph = load_graph(graph_path)
    course_id = str(args.course_id or graph.get("course_id") or "unknown")
    output_path = Path(args.output) if args.output else default_output_path(course_id)
    write_preview(graph, output_path)
    print(json.dumps({"ok": True, "graph_path": str(graph_path), "output_path": str(output_path)}, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())
