#!/usr/bin/env python3
"""Build the local alignment preview graph artifact.

This is the PR 2 skeleton for the alignment preview plan. It is intentionally
local-only: it reads course-config.json and writes courses/{id}/alignment/graph.json.
Live Canvas extraction and HTML rendering belong to later PRs.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


PLUGIN_ROOT = Path(__file__).resolve().parents[1]
SCHEMA_VERSION = "alignment-graph-preview/v1"

NODE_TYPES = {
    "clo",
    "mlo",
    "page",
    "video",
    "reading",
    "assignment",
    "discussion",
    "quiz",
    "quiz_question",
    "rubric_criterion",
}

EDGE_TYPES = {
    "aligns_to",
    "supports",
    "assesses",
    "contains",
    "uses",
    "needs_review",
}

CONFIDENCE_VALUES = {"EXTRACTED", "INFERRED", "AMBIGUOUS"}
CONFIG_SOURCE = "course-config"


def _slugify(value: Any, max_length: int = 72) -> str:
    text = str(value or "").strip().lower()
    text = re.sub(r"[^a-z0-9]+", "-", text)
    return text.strip("-")[:max_length] or "item"


def _stable_id(prefix: str, *parts: Any) -> str:
    raw = "|".join(str(part) for part in parts if part is not None and str(part) != "")
    if not raw:
        raw = prefix
    digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
    return f"{prefix}:{_slugify(raw)}:{digest}"


def _edge_id(edge_type: str, source: str, target: str, source_ref: str = "") -> str:
    return _stable_id("edge", edge_type, source, target, source_ref)


def _ensure_list(value: Any) -> list[Any]:
    if value is None:
        return []
    if isinstance(value, list):
        return value
    if isinstance(value, tuple):
        return list(value)
    return [value]


def _text_from_item(item: Any) -> str:
    if isinstance(item, str):
        return item.strip()
    if not isinstance(item, dict):
        return str(item or "").strip()
    for key in ("text", "title", "name", "prompt", "question", "description", "objective"):
        if item.get(key):
            return str(item[key]).strip()
    return ""


def _label_from_item(item: Any, fallback: str) -> str:
    if isinstance(item, dict):
        for key in ("id", "label", "title", "name"):
            if item.get(key):
                return str(item[key]).strip()
    text = _text_from_item(item)
    return text[:80] if text else fallback


def _refs_from_item(item: Any, keys: tuple[str, ...]) -> tuple[list[str], str | None]:
    if not isinstance(item, dict):
        return [], None
    for key in keys:
        if key in item and item[key] not in (None, "", []):
            return [str(ref).strip() for ref in _ensure_list(item[key]) if str(ref).strip()], key
    return [], None


def _normalize_ref(value: Any) -> str:
    return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())


def _course_id_from_config(course_config: dict[str, Any], explicit_course_id: str | None = None) -> str:
    if explicit_course_id:
        return str(explicit_course_id)
    course = course_config.get("course") if isinstance(course_config.get("course"), dict) else {}
    for value in (course.get("id"), course_config.get("course_id"), course_config.get("id")):
        if value:
            return str(value)
    return "unknown"


def _course_title(course_config: dict[str, Any], course_id: str) -> str:
    course = course_config.get("course") if isinstance(course_config.get("course"), dict) else {}
    return str(course.get("title") or course.get("name") or course_config.get("title") or f"Course {course_id}")


def _new_graph(course_config: dict[str, Any], course_id: str) -> dict[str, Any]:
    return {
        "schema_version": SCHEMA_VERSION,
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "course_id": course_id,
        "course_title": _course_title(course_config, course_id),
        "source_mode": "local-course-config",
        "conflict_policy": (
            "course-config is intended design; future Canvas evidence is implemented state; "
            "conflicts must be preserved as AMBIGUOUS needs_review evidence, not silently overwritten."
        ),
        "nodes": [],
        "edges": [],
        "gaps": {},
        "validation_issues": [],
    }


def _add_node(
    graph: dict[str, Any],
    *,
    node_id: str,
    node_type: str,
    label: str,
    text: str = "",
    source_ref: str = "",
    metadata: dict[str, Any] | None = None,
    source: str = CONFIG_SOURCE,
    source_file: str = "course-config.json",
    canvas_url: str = "",
) -> dict[str, Any]:
    node = {
        "id": node_id,
        "type": node_type,
        "label": label,
        "text": text,
        "source": source,
        "source_file": source_file,
        "source_ref": source_ref,
        "metadata": metadata or {},
    }
    if canvas_url:
        node["canvas_url"] = canvas_url
    graph["nodes"].append(node)
    return node


def _add_edge(
    graph: dict[str, Any],
    *,
    edge_type: str,
    source: str,
    target: str,
    confidence: str,
    evidence: str,
    source_ref: str = "",
    metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
    edge = {
        "id": _edge_id(edge_type, source, target, source_ref),
        "type": edge_type,
        "source": source,
        "target": target,
        "confidence": confidence,
        "evidence": evidence,
        "source_ref": source_ref,
        "metadata": metadata or {},
    }
    graph["edges"].append(edge)
    return edge


def _iter_clos(course_config: dict[str, Any]) -> list[tuple[dict[str, Any], str]]:
    clos = []
    seen = set()
    for field in ("clos", "clos_draft", "course_learning_outcomes", "learning_outcomes"):
        for index, raw in enumerate(_ensure_list(course_config.get(field)), start=1):
            if not raw:
                continue
            if isinstance(raw, str):
                item = {"id": f"CLO{index}", "text": raw}
            elif isinstance(raw, dict):
                item = dict(raw)
                item.setdefault("id", f"CLO{index}")
            else:
                continue
            key = _normalize_ref(item.get("id") or item.get("text"))
            if not key or key in seen:
                continue
            seen.add(key)
            clos.append((item, f"{field}[{index - 1}]"))
    return clos


_MODULE_NAME_NUMBER_RE = re.compile(r"\bmodule\s+(\d+)\b", re.IGNORECASE)


def _module_number(module: dict[str, Any], fallback: int) -> int:
    # Prefer an explicit number/module_number field when the source provides one.
    for key in ("number", "module_number"):
        value = module.get(key)
        if value is not None:
            try:
                return int(value)
            except (TypeError, ValueError):
                pass
    # Canvas modules expose `position`, but position is visual order on the page
    # (includes Module 0, Spring Break, Links & Tools, etc.) and does NOT match
    # the instructor's "Module N" numbering. Parse the number out of the title
    # when possible so config M10 actually matches Canvas "Module 10".
    name = str(module.get("name") or module.get("title") or "")
    match = _MODULE_NAME_NUMBER_RE.search(name)
    if match:
        try:
            return int(match.group(1))
        except ValueError:
            pass
    # Last resort: fall back to position (legacy behavior) or the supplied index.
    position = module.get("position")
    if position is not None:
        try:
            return int(position)
        except (TypeError, ValueError):
            pass
    return fallback


def _module_mlo_items(module: dict[str, Any]) -> list[tuple[Any, str]]:
    items = []
    for field in ("objectives", "mlos", "mlos_draft", "module_learning_objectives", "learning_objectives"):
        for index, raw in enumerate(_ensure_list(module.get(field)), start=1):
            if raw:
                items.append((raw, f"{field}[{index - 1}]"))
    return items


def _module_clo_refs(module: dict[str, Any]) -> list[str]:
    refs, _ = _refs_from_item(module, ("clo_ids", "clos", "clo", "ladders_to"))
    return refs


def _target_mlo_ids(
    item: Any,
    module_mlo_ids: list[str],
    *,
    explicit_keys: tuple[str, ...] = ("mlo_ids", "mlos", "mlo", "ladders_to", "aligns_to"),
) -> tuple[list[str], str, str | None]:
    refs, key = _refs_from_item(item, explicit_keys)
    if refs:
        return refs, "EXTRACTED", key
    return list(module_mlo_ids), "INFERRED", None


def _iter_material_specs(module: dict[str, Any]) -> list[dict[str, Any]]:
    specs: list[dict[str, Any]] = []
    field_defaults = {
        "learning_materials": "page",
        "materials": "page",
        "readings": "reading",
        "pages": "page",
        "videos": "video",
    }
    for field, default_type in field_defaults.items():
        for index, raw in enumerate(_ensure_list(module.get(field)), start=1):
            if raw:
                specs.append({
                    "raw": raw,
                    "node_type": raw.get("type", default_type) if isinstance(raw, dict) else default_type,
                    "source_ref": f"{field}[{index - 1}]",
                })

    for index, topic in enumerate(_ensure_list(module.get("lesson_topics")), start=1):
        if topic:
            specs.append({
                "raw": {"title": f"Lesson topic: {topic}", "text": str(topic)},
                "node_type": "page",
                "source_ref": f"lesson_topics[{index - 1}]",
            })

    if module.get("key_content"):
        specs.append({
            "raw": {
                "title": "Key content",
                "text": str(module.get("key_content")),
            },
            "node_type": "page",
            "source_ref": "key_content",
        })

    for index, lecture in enumerate(_ensure_list(module.get("lectures")), start=1):
        if lecture:
            specs.append({
                "raw": {"title": str(lecture), "text": str(lecture)},
                "node_type": "video",
                "source_ref": f"lectures[{index - 1}]",
            })
    return specs


def _assessment_from_dict(raw: dict[str, Any], default_type: str, default_title: str) -> dict[str, Any]:
    item = dict(raw)
    item.setdefault("type", default_type)
    item.setdefault("title", item.get("name") or default_title)
    return item


def _iter_assessment_specs(module: dict[str, Any]) -> list[dict[str, Any]]:
    specs: list[dict[str, Any]] = []
    assessments = module.get("assessments")
    if isinstance(assessments, list):
        for index, raw in enumerate(assessments, start=1):
            if isinstance(raw, dict):
                item = _assessment_from_dict(raw, raw.get("type", "assignment"), f"Assessment {index}")
            elif raw:
                item = {"title": str(raw), "type": "assignment"}
            else:
                continue
            specs.append({"raw": item, "source_ref": f"assessments[{index - 1}]"})
    elif isinstance(assessments, dict):
        dict_specs = (
            ("knowledge_check", "quiz", "Knowledge Check"),
            ("quiz", "quiz", "Quiz"),
            ("guided_practice", "assignment", "Guided Practice"),
            ("artifact", "assignment", "Create an Artifact"),
            ("assignment", "assignment", "Assignment"),
            ("discussion", "discussion", "Discussion"),
        )
        for key, default_type, default_title in dict_specs:
            value = assessments.get(key)
            if not value:
                continue
            if isinstance(value, dict):
                item = _assessment_from_dict(value, default_type, default_title)
            elif isinstance(value, str):
                item = {"title": value, "type": default_type, "text": value}
            else:
                item = {"title": default_title, "type": default_type, "text": str(value)}
            specs.append({"raw": item, "source_ref": f"assessments.{key}"})

    if module.get("artifact"):
        specs.append({
            "raw": {"title": str(module["artifact"]), "text": str(module["artifact"]), "type": "assignment"},
            "source_ref": "artifact",
        })

    for index, assignment in enumerate(_ensure_list(module.get("assignments_referenced")), start=1):
        if assignment:
            specs.append({
                "raw": {"title": str(assignment), "text": str(assignment), "type": "assignment"},
                "source_ref": f"assignments_referenced[{index - 1}]",
            })

    return specs


def _node_type_for_assessment(raw: dict[str, Any]) -> str:
    value = str(raw.get("type") or raw.get("kind") or "").lower()
    if "quiz" in value or "knowledge" in value:
        return "quiz"
    if "discussion" in value:
        return "discussion"
    return "assignment"


def _iter_questions(raw: dict[str, Any]) -> list[tuple[Any, str]]:
    questions = raw.get("questions") or raw.get("quiz_questions") or raw.get("items")
    return [(item, f"questions[{idx - 1}]") for idx, item in enumerate(_ensure_list(questions), start=1) if item]


def _iter_rubric_criteria(raw: dict[str, Any]) -> list[tuple[Any, str]]:
    criteria = raw.get("rubric") or raw.get("rubrics") or raw.get("criteria") or raw.get("rubric_criteria")
    return [(item, f"rubric[{idx - 1}]") for idx, item in enumerate(_ensure_list(criteria), start=1) if item]


def _plain_text(value: Any) -> str:
    return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", str(value or ""))).strip()


def _canvas_course_url(config: dict[str, Any]) -> str:
    return f"https://{config.get('domain', 'canvas.asu.edu')}/courses/{config.get('course_id')}"


def _canvas_item_url(config: dict[str, Any], item_type: str, item_id: Any = "", page_slug: str = "") -> str:
    base = _canvas_course_url(config)
    if item_type == "Page" and page_slug:
        return f"{base}/pages/{page_slug}"
    if item_type == "Assignment" and item_id:
        return f"{base}/assignments/{item_id}"
    if item_type == "Quiz" and item_id:
        return f"{base}/quizzes/{item_id}"
    if item_type == "Discussion" and item_id:
        return f"{base}/discussion_topics/{item_id}"
    return base


def _mlo_ids_by_module(graph: dict[str, Any]) -> dict[int, list[str]]:
    by_module: dict[int, list[str]] = {}
    for node in graph.get("nodes", []):
        if node.get("type") != "mlo":
            continue
        module = (node.get("metadata") or {}).get("module_number")
        if module is None:
            continue
        try:
            module_number = int(module)
        except (TypeError, ValueError):
            continue
        by_module.setdefault(module_number, []).append(node["id"])
    return by_module


def _local_node_index(graph: dict[str, Any]) -> dict[tuple[str, int, str], dict[str, Any]]:
    index: dict[tuple[str, int, str], dict[str, Any]] = {}
    for node in graph.get("nodes", []):
        if node.get("source") != CONFIG_SOURCE:
            continue
        module = (node.get("metadata") or {}).get("module_number")
        try:
            module_number = int(module)
        except (TypeError, ValueError):
            module_number = 0
        index[(str(node.get("type")), module_number, _normalize_ref(node.get("label")))] = node
    return index


def _mark_config_canvas_conflict(
    graph: dict[str, Any],
    *,
    canvas_node: dict[str, Any],
    local_index: dict[tuple[str, int, str], dict[str, Any]],
    source_ref: str,
) -> None:
    module = (canvas_node.get("metadata") or {}).get("module_number")
    try:
        module_number = int(module)
    except (TypeError, ValueError):
        module_number = 0
    local_node = local_index.get((str(canvas_node.get("type")), module_number, _normalize_ref(canvas_node.get("label"))))
    if not local_node:
        return
    local_text = _plain_text(local_node.get("text"))
    canvas_text = _plain_text(canvas_node.get("text"))
    if not local_text or not canvas_text or local_text == canvas_text:
        return
    _add_edge(
        graph,
        edge_type="needs_review",
        source=canvas_node["id"],
        target=local_node["id"],
        confidence="AMBIGUOUS",
        evidence=(
            f"Canvas item '{canvas_node.get('label')}' differs from the local course-config item with the same "
            "type, module, and label. Preserve both for review."
        ),
        source_ref=source_ref,
    )


def _lookup_by_id(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    return {str(item.get("id")): item for item in items if item.get("id") is not None}


def _add_inferred_module_edges(
    graph: dict[str, Any],
    *,
    node_id: str,
    label: str,
    module_number: int,
    target_mlos: list[str],
    edge_type: str,
    source_ref: str,
) -> None:
    for mlo_id in target_mlos:
        _add_edge(
            graph,
            edge_type=edge_type,
            source=node_id,
            target=mlo_id,
            confidence="INFERRED",
            evidence=f"Canvas item '{label}' appears in Module {module_number}; linked to module MLOs for review.",
            source_ref=source_ref,
        )


def _analyze_preview_gaps(graph: dict[str, Any]) -> dict[str, list[str]]:
    nodes_by_id = {node["id"]: node for node in graph.get("nodes", [])}
    mlo_ids = {node["id"] for node in graph.get("nodes", []) if node.get("type") == "mlo"}
    supported = {edge["target"] for edge in graph.get("edges", []) if edge.get("type") == "supports"}
    assessed = {edge["target"] for edge in graph.get("edges", []) if edge.get("type") in {"assesses", "aligns_to"}}
    mlo_to_clo = {edge["source"] for edge in graph.get("edges", []) if edge.get("type") == "aligns_to" and edge.get("target") not in mlo_ids}
    question_to_mlo = {edge["source"] for edge in graph.get("edges", []) if edge.get("type") == "aligns_to" and edge.get("target") in mlo_ids}
    criterion_to_mlo = {edge["source"] for edge in graph.get("edges", []) if edge.get("type") == "assesses" and edge.get("source") in nodes_by_id and nodes_by_id[edge["source"]].get("type") == "rubric_criterion"}
    assessment_to_mlo = {edge["source"] for edge in graph.get("edges", []) if edge.get("type") == "assesses"}

    return {
        "mlo_without_clo": sorted(mlo_ids - mlo_to_clo),
        "mlo_without_material": sorted(mlo_ids - supported),
        "mlo_without_assessment": sorted(mlo_ids - assessed),
        "assessment_without_mlo": sorted(
            node["id"]
            for node in graph.get("nodes", [])
            if node.get("type") in {"assignment", "discussion", "quiz"} and node["id"] not in assessment_to_mlo
        ),
        "quiz_question_without_mlo": sorted(
            node["id"]
            for node in graph.get("nodes", [])
            if node.get("type") == "quiz_question" and node["id"] not in question_to_mlo
        ),
        "rubric_criterion_without_mlo": sorted(
            node["id"]
            for node in graph.get("nodes", [])
            if node.get("type") == "rubric_criterion" and node["id"] not in criterion_to_mlo
        ),
        "ambiguous_edges": sorted(edge["id"] for edge in graph.get("edges", []) if edge.get("confidence") == "AMBIGUOUS"),
    }


def build_graph_from_course_config(course_config: dict[str, Any], course_id: str | None = None) -> dict[str, Any]:
    course_id = _course_id_from_config(course_config, course_id)
    graph = _new_graph(course_config, course_id)

    clo_ref_to_node_id: dict[str, str] = {}
    for raw, source_ref in _iter_clos(course_config):
        clo_id = str(raw.get("id") or _label_from_item(raw, "CLO"))
        text = _text_from_item(raw)
        node_id = _stable_id("clo", clo_id)
        _add_node(
            graph,
            node_id=node_id,
            node_type="clo",
            label=str(raw.get("label") or clo_id),
            text=text,
            source_ref=source_ref,
            metadata={key: value for key, value in raw.items() if key not in {"id", "label", "text"}},
        )
        for ref in (clo_id, raw.get("label"), raw.get("text")):
            if ref:
                clo_ref_to_node_id[_normalize_ref(ref)] = node_id

    module_mlos: dict[int, list[str]] = {}
    mlo_ref_to_node_id: dict[str, str] = {}
    mlo_edge_work: list[tuple[str, list[str], str, str, str]] = []
    modules = [module for module in _ensure_list(course_config.get("modules")) if isinstance(module, dict)]

    for module_index, module in enumerate(modules, start=1):
        mod_number = _module_number(module, module_index)
        mod_title = str(module.get("title") or f"Module {mod_number}")
        module_clos = _module_clo_refs(module)
        for mlo_index, (raw, local_ref) in enumerate(_module_mlo_items(module), start=1):
            if isinstance(raw, dict):
                mlo_id = str(raw.get("id") or f"M{mod_number}.{mlo_index}")
                mlo_refs, ref_key = _refs_from_item(raw, ("clo_ids", "clos", "clo", "ladders_to"))
                confidence = "EXTRACTED" if mlo_refs else "INFERRED"
                clo_refs = mlo_refs or module_clos
            else:
                mlo_id = f"M{mod_number}.{mlo_index}"
                ref_key = None
                confidence = "INFERRED"
                clo_refs = module_clos
            text = _text_from_item(raw)
            node_id = _stable_id("mlo", mlo_id)
            _add_node(
                graph,
                node_id=node_id,
                node_type="mlo",
                label=mlo_id,
                text=text,
                source_ref=f"modules[{module_index - 1}].{local_ref}",
                metadata={
                    "module_number": mod_number,
                    "module_title": mod_title,
                    **({key: value for key, value in raw.items() if key not in {"id", "text", "clo", "clos", "clo_ids", "ladders_to"}} if isinstance(raw, dict) else {}),
                },
            )
            module_mlos.setdefault(mod_number, []).append(node_id)
            for ref in (mlo_id, text):
                if ref:
                    mlo_ref_to_node_id[_normalize_ref(ref)] = node_id
            mlo_edge_work.append((node_id, clo_refs, confidence, f"modules[{module_index - 1}].{local_ref}.{ref_key or 'module_clos'}", text))

    for source_node_id, clo_refs, confidence, source_ref, mlo_text in mlo_edge_work:
        for clo_ref in clo_refs:
            target_node_id = clo_ref_to_node_id.get(_normalize_ref(clo_ref))
            if not target_node_id:
                continue
            evidence = (
                f"MLO '{mlo_text}' declares CLO reference {clo_ref} in course-config.json."
                if confidence == "EXTRACTED"
                else f"Module-level CLO reference {clo_ref} is applied to MLO '{mlo_text}' for local preview."
            )
            _add_edge(
                graph,
                edge_type="aligns_to",
                source=source_node_id,
                target=target_node_id,
                confidence=confidence,
                evidence=evidence,
                source_ref=source_ref,
            )

    for module_index, module in enumerate(modules, start=1):
        mod_number = _module_number(module, module_index)
        mod_title = str(module.get("title") or f"Module {mod_number}")
        target_mlos = module_mlos.get(mod_number, [])

        for spec_index, spec in enumerate(_iter_material_specs(module), start=1):
            raw = spec["raw"]
            requested_type = str(spec.get("node_type") or "page")
            node_type = requested_type if requested_type in {"page", "video", "reading"} else "page"
            label = _label_from_item(raw, f"Material {spec_index}")
            text = _text_from_item(raw)
            node_id = _stable_id(node_type, course_id, mod_number, spec["source_ref"], label)
            _add_node(
                graph,
                node_id=node_id,
                node_type=node_type,
                label=label,
                text=text,
                source_ref=f"modules[{module_index - 1}].{spec['source_ref']}",
                metadata={"module_number": mod_number, "module_title": mod_title},
            )
            refs, confidence, ref_key = _target_mlo_ids(raw, target_mlos)
            for ref in refs:
                target_node_id = mlo_ref_to_node_id.get(_normalize_ref(ref), ref if ref in target_mlos else None)
                if not target_node_id:
                    continue
                evidence = (
                    f"Material '{label}' declares MLO reference {ref} in course-config.json."
                    if confidence == "EXTRACTED"
                    else f"Material '{label}' appears in Module {mod_number}; linked to module MLOs for local preview."
                )
                _add_edge(
                    graph,
                    edge_type="supports",
                    source=node_id,
                    target=target_node_id,
                    confidence=confidence,
                    evidence=evidence,
                    source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{ref_key or 'module_mlos'}",
                )

        for spec_index, spec in enumerate(_iter_assessment_specs(module), start=1):
            raw = spec["raw"]
            node_type = _node_type_for_assessment(raw)
            label = _label_from_item(raw, f"Assessment {spec_index}")
            text = _text_from_item(raw)
            node_id = _stable_id(node_type, course_id, mod_number, spec["source_ref"], label)
            _add_node(
                graph,
                node_id=node_id,
                node_type=node_type,
                label=label,
                text=text,
                source_ref=f"modules[{module_index - 1}].{spec['source_ref']}",
                metadata={"module_number": mod_number, "module_title": mod_title},
            )
            refs, confidence, ref_key = _target_mlo_ids(raw, target_mlos)
            mapped_mlos: list[str] = []
            for ref in refs:
                target_node_id = mlo_ref_to_node_id.get(_normalize_ref(ref), ref if ref in target_mlos else None)
                if not target_node_id:
                    continue
                mapped_mlos.append(target_node_id)
                evidence = (
                    f"Assessment '{label}' declares MLO reference {ref} in course-config.json."
                    if confidence == "EXTRACTED"
                    else f"Assessment '{label}' appears in Module {mod_number}; linked to module MLOs for local preview."
                )
                _add_edge(
                    graph,
                    edge_type="assesses",
                    source=node_id,
                    target=target_node_id,
                    confidence=confidence,
                    evidence=evidence,
                    source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{ref_key or 'module_mlos'}",
                )

            for question_index, (question, question_ref) in enumerate(_iter_questions(raw), start=1):
                q_text = _text_from_item(question)
                q_label = _label_from_item(question, f"Question {question_index}")
                q_node_id = _stable_id("quiz_question", course_id, mod_number, label, question_index, q_text)
                _add_node(
                    graph,
                    node_id=q_node_id,
                    node_type="quiz_question",
                    label=q_label,
                    text=q_text,
                    source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{question_ref}",
                    metadata={"module_number": mod_number, "assessment_id": node_id},
                )
                _add_edge(
                    graph,
                    edge_type="contains",
                    source=node_id,
                    target=q_node_id,
                    confidence="EXTRACTED",
                    evidence=f"Quiz '{label}' contains question '{q_label}' in course-config.json.",
                    source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{question_ref}",
                )
                q_refs, q_confidence, q_ref_key = _target_mlo_ids(question, [])
                if q_refs:
                    for ref in q_refs:
                        target_node_id = mlo_ref_to_node_id.get(_normalize_ref(ref))
                        if not target_node_id:
                            continue
                        _add_edge(
                            graph,
                            edge_type="aligns_to",
                            source=q_node_id,
                            target=target_node_id,
                            confidence=q_confidence,
                            evidence=f"Quiz question '{q_label}' declares MLO reference {ref} in course-config.json.",
                            source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{question_ref}.{q_ref_key or 'mlo'}",
                        )
                else:
                    _add_edge(
                        graph,
                        edge_type="needs_review",
                        source=q_node_id,
                        target=node_id,
                        confidence="AMBIGUOUS",
                        evidence=f"Quiz question '{q_label}' has no explicit MLO reference in course-config.json.",
                        source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{question_ref}",
                    )

            for criterion_index, (criterion, criterion_ref) in enumerate(_iter_rubric_criteria(raw), start=1):
                c_text = _text_from_item(criterion)
                c_label = _label_from_item(criterion, f"Criterion {criterion_index}")
                c_node_id = _stable_id("rubric_criterion", course_id, mod_number, label, criterion_index, c_label)
                _add_node(
                    graph,
                    node_id=c_node_id,
                    node_type="rubric_criterion",
                    label=c_label,
                    text=c_text,
                    source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{criterion_ref}",
                    metadata={"module_number": mod_number, "assessment_id": node_id},
                )
                _add_edge(
                    graph,
                    edge_type="contains",
                    source=node_id,
                    target=c_node_id,
                    confidence="EXTRACTED",
                    evidence=f"Assessment '{label}' contains rubric criterion '{c_label}' in course-config.json.",
                    source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{criterion_ref}",
                )
                c_refs, c_confidence, c_ref_key = _target_mlo_ids(criterion, mapped_mlos)
                for ref in c_refs:
                    target_node_id = mlo_ref_to_node_id.get(_normalize_ref(ref), ref if ref in mapped_mlos else None)
                    if not target_node_id:
                        continue
                    _add_edge(
                        graph,
                        edge_type="assesses",
                        source=c_node_id,
                        target=target_node_id,
                        confidence=c_confidence,
                        evidence=(
                            f"Rubric criterion '{c_label}' declares MLO reference {ref} in course-config.json."
                            if c_confidence == "EXTRACTED"
                            else f"Rubric criterion '{c_label}' is attached to assessment '{label}', which is linked to module MLOs."
                        ),
                        source_ref=f"modules[{module_index - 1}].{spec['source_ref']}.{criterion_ref}.{c_ref_key or 'assessment_mlos'}",
                    )

    graph["gaps"] = _analyze_preview_gaps(graph)
    graph["validation_issues"] = validate_graph_schema(graph)
    return graph


# ---------------------------------------------------------------------------
# Canvas-first CLO / MLO extraction (Phase 1 of plan-alignment-mode-b-canvas-first-graphify.md)
# ---------------------------------------------------------------------------

# Allow up to ~1KB of intervening HTML (closing tags, headers, prose <p>
# preambles like "Students completing this course will be able to:") between
# the anchor phrase and the bullet list. Real Canvas syllabi commonly wrap
# the bullet list with a separate paragraph; the previous closing-tags-only
# allowance missed that shape and dropped CLO extraction silently.
_NON_INSTRUCTIONAL_PAGE_TITLE_RE = re.compile(
    r"(?i)("
    r"\boverview\b|\btask\s*list\b|^\s*mockup:|^\s*\[instructors?:|"
    r"\bsyllabus\b|\btechnology\s+support\b|\bzoom\s+for\s+students\b|"
    r"\btutoring\b|\bhonorlock\b|\blibrary\s+resource\b|"
    r"\binstructor\s+guide\b|\binstructor\s+resources?\b|\bfacilitation\b|"
    r"\bpreparation\s+and\s+facilitation\b|\boffice\s+hours\b|"
    r"\bquality\s+assurance\b|\bmyasu\b|\bgrader\s+tasks?\b|\bta\s+or\s+ia\b|"
    r"\bcourse\s+evaluation\b|\bclass\s+forum\b|\bai\s+tutor\b|"
    r"\bzoom\s+setting\b|\bconclusion\s+and\s+reflection\b"
    r")"
)


def _is_instructional_page_title(title: str | None) -> bool:
    """Return True when a Canvas Page title looks like learning material.

    Filters out navigation/admin pages (Overview, Task List), instructor-only
    pages (Honorlock, Office Hours, Instructor Guide), student-support pages
    (Tutoring, Tech Support, Zoom For Students), mockup chrome (`Mockup: ...`),
    and template placeholders (`[Instructors: Update the page text...]`).
    Lecture / Video / Reading / Textbook pages and final-project content
    survive the filter.
    """
    if not title:
        return False
    if _NON_INSTRUCTIONAL_PAGE_TITLE_RE.search(title):
        return False
    return True


_CLO_ANCHOR_RE = re.compile(
    r"(?is)(?:students?\s+will\s+be\s+able\s+to|learning\s+outcomes|course\s+(?:learning\s+)?outcomes|"
    r"student\s+learning\s+outcomes)\s*[:.]?\s*(?:<[^>]+>|[^<]){0,1024}?<(ul|ol)[^>]*>(.*?)</\1>"
)

_MLO_ANCHOR_RE = re.compile(
    r"(?is)(?:learning\s+objectives|module\s+(?:learning\s+)?objectives|module\s+outcomes)\s*"
    r"(?:</[^>]+>\s*)*(?:<[^>]+>\s*(?:by\s+the\s+end[^<]*)?</[^>]+>\s*)*"
    r"<(ul|ol)[^>]*>(.*?)</\1>"
)


def _strip_html(value: Any) -> str:
    text = re.sub(r"<[^>]+>", " ", str(value or ""))
    text = text.replace("&nbsp;", " ").replace("&amp;", "&")
    text = re.sub(r"\s+", " ", text).strip()
    return text


def _normalize_for_text_compare(value: Any) -> str:
    """Normalize text for divergence comparison.

    Strips HTML, collapses whitespace, trims trailing punctuation, and
    lowercases so that 'Identify the legal system.' and 'identify the
    legal system' compare equal. Used by the config-vs-Canvas conflict
    detector to avoid false-positive `needs_review` edges on cosmetic
    differences.
    """
    text = _strip_html(value)
    text = text.rstrip(" .;,:!?")
    return text.lower()


def _bullets_from_list_html(list_html: str) -> list[str]:
    bullets: list[str] = []
    for raw in re.findall(r"(?is)<li[^>]*>(.*?)</li>", list_html):
        text = _strip_html(raw).rstrip(".").strip()
        if text and len(text) > 10:
            bullets.append(text)
    return bullets


def extract_clos_from_syllabus(syllabus_body: str | None) -> list[dict[str, Any]]:
    """Extract CLOs from a Canvas syllabus_body HTML string.

    Returns a list of dicts with id / label / text / confidence. confidence is
    `high` when the regex matched a well-formed bullet list; `low` when the
    extractor had to recover. Returns [] when no anchor is found.

    Public helper so `alignment_skill.py` regex pre-pass can consolidate onto
    the same code path in a future PR.
    """
    if not syllabus_body:
        return []
    match = _CLO_ANCHOR_RE.search(syllabus_body)
    if not match:
        return []
    bullets = _bullets_from_list_html(match.group(2))
    if not bullets:
        return []
    confidence = "high" if len(bullets) >= 2 else "low"
    clos: list[dict[str, Any]] = []
    for index, text in enumerate(bullets, start=1):
        clos.append({
            "id": f"CLO{index}",
            "label": f"CLO{index}",
            "text": text,
            "confidence": confidence,
        })
    return clos


def extract_mlos_from_overview(
    page_body: str | None,
    module_number: int,
) -> list[dict[str, Any]]:
    """Extract MLOs from a Canvas module overview page body HTML string.

    Returns a list of dicts with id / label / text / confidence. MLO IDs follow
    the `MLO{module_number}.{i}` convention so they match the local-config
    extractor's stable IDs and can be deduplicated on collision.
    """
    if not page_body:
        return []
    match = _MLO_ANCHOR_RE.search(page_body)
    if not match:
        return []
    bullets = _bullets_from_list_html(match.group(2))
    # Drop template placeholders like "<topic>" that escape regex strip.
    bullets = [b for b in bullets if not re.fullmatch(r"<[^>]+>", b)]
    if not bullets:
        return []
    confidence = "high" if len(bullets) >= 2 else "low"
    mlos: list[dict[str, Any]] = []
    for index, text in enumerate(bullets, start=1):
        mid = f"MLO{module_number}.{index}"
        mlos.append({
            "id": mid,
            "label": mid,
            "text": text,
            "confidence": confidence,
        })
    return mlos


def _find_module_overview_slug(items: list[dict[str, Any]]) -> str | None:
    for item in items or []:
        if str(item.get("type") or "") != "Page":
            continue
        title = str(item.get("title") or "").lower()
        if "overview" in title:
            return item.get("page_url") or item.get("content_id") or None
    return None


def _normalize_label_for_dedup(value: Any) -> str:
    return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())


def _existing_clo_labels(graph: dict[str, Any]) -> set[str]:
    return {
        _normalize_label_for_dedup(node.get("label"))
        for node in graph.get("nodes", [])
        if node.get("type") == "clo"
    }


def _existing_mlo_by_module_and_label(graph: dict[str, Any]) -> dict[tuple[int, str], dict[str, Any]]:
    out: dict[tuple[int, str], dict[str, Any]] = {}
    for node in graph.get("nodes", []):
        if node.get("type") != "mlo":
            continue
        mod = (node.get("metadata") or {}).get("module_number")
        try:
            mod_num = int(mod) if mod is not None else 0
        except (TypeError, ValueError):
            mod_num = 0
        key = (mod_num, _normalize_label_for_dedup(node.get("label")))
        out[key] = node
    return out


def _add_canvas_clo_nodes_and_edges(
    graph: dict[str, Any],
    course_id: str,
    domain: str,
    canvas_clos: list[dict[str, Any]],
) -> None:
    """Insert canvas-sourced CLO nodes for those not already declared in config.

    When a config CLO already exists with the same normalized label, the
    config CLO wins (intended design). If the Canvas text differs from the
    config text, emit an AMBIGUOUS needs_review edge so the conflict surfaces.
    """
    existing = _existing_clo_labels(graph)
    syllabus_url = f"https://{domain}/courses/{course_id}/assignments/syllabus"
    for raw in canvas_clos:
        norm = _normalize_label_for_dedup(raw.get("label"))
        if norm in existing:
            # Find the config CLO node; if text differs, emit needs_review edge.
            config_node = next(
                (n for n in graph.get("nodes", []) if n.get("type") == "clo" and _normalize_label_for_dedup(n.get("label")) == norm),
                None,
            )
            if config_node and _normalize_for_text_compare(config_node.get("text")) != _normalize_for_text_compare(raw.get("text")):
                # Add a transient canvas node to anchor the conflict edge.
                canvas_node_id = _stable_id("canvas-clo-conflict", course_id, raw.get("label"))
                _add_node(
                    graph,
                    node_id=canvas_node_id,
                    node_type="clo",
                    label=str(raw.get("label")),
                    text=str(raw.get("text") or ""),
                    source="canvas",
                    source_file="syllabus_body",
                    source_ref="syllabus_body",
                    canvas_url=syllabus_url,
                    metadata={"confidence_extraction": raw.get("confidence", "low"), "config_override": True},
                )
                _add_edge(
                    graph,
                    edge_type="needs_review",
                    source=canvas_node_id,
                    target=config_node["id"],
                    confidence="AMBIGUOUS",
                    evidence=(
                        f"Canvas syllabus CLO text differs from the local course-config CLO "
                        f"with the same label '{raw.get('label')}'. Preserve both for review."
                    ),
                    source_ref="syllabus_body",
                )
            continue
        # No config match — add the Canvas CLO directly.
        node_id = _stable_id("canvas-clo", course_id, raw.get("label"))
        _add_node(
            graph,
            node_id=node_id,
            node_type="clo",
            label=str(raw.get("label")),
            text=str(raw.get("text") or ""),
            source="canvas",
            source_file="syllabus_body",
            source_ref="syllabus_body",
            canvas_url=syllabus_url,
            metadata={"confidence_extraction": raw.get("confidence", "low")},
        )


def _add_canvas_mlo_nodes_and_edges(
    graph: dict[str, Any],
    course_id: str,
    domain: str,
    module_number: int,
    module_title: str,
    overview_slug: str | None,
    canvas_mlos: list[dict[str, Any]],
) -> list[str]:
    """Insert canvas-sourced MLO nodes for those not already declared in config.

    Returns the list of MLO node IDs (config + canvas combined) for the
    module, so downstream item linking can use the full target set.
    """
    existing_by_key = _existing_mlo_by_module_and_label(graph)
    overview_url = (
        f"https://{domain}/courses/{course_id}/pages/{overview_slug}"
        if overview_slug
        else f"https://{domain}/courses/{course_id}"
    )
    added_ids: list[str] = []
    for raw in canvas_mlos:
        norm = _normalize_label_for_dedup(raw.get("label"))
        key = (module_number, norm)
        config_node = existing_by_key.get(key)
        if config_node:
            if _normalize_for_text_compare(config_node.get("text")) != _normalize_for_text_compare(raw.get("text")):
                canvas_node_id = _stable_id("canvas-mlo-conflict", course_id, module_number, raw.get("label"))
                _add_node(
                    graph,
                    node_id=canvas_node_id,
                    node_type="mlo",
                    label=str(raw.get("label")),
                    text=str(raw.get("text") or ""),
                    source="canvas",
                    source_file=f"pages/{overview_slug}" if overview_slug else "canvas",
                    source_ref=f"pages/{overview_slug}" if overview_slug else "canvas",
                    canvas_url=overview_url,
                    metadata={
                        "module_number": module_number,
                        "module_title": module_title,
                        "confidence_extraction": raw.get("confidence", "low"),
                        "config_override": True,
                    },
                )
                _add_edge(
                    graph,
                    edge_type="needs_review",
                    source=canvas_node_id,
                    target=config_node["id"],
                    confidence="AMBIGUOUS",
                    evidence=(
                        f"Canvas module overview MLO text differs from the local course-config MLO "
                        f"with the same label '{raw.get('label')}'. Preserve both for review."
                    ),
                    source_ref=f"pages/{overview_slug}" if overview_slug else "canvas",
                )
            continue
        node_id = _stable_id("canvas-mlo", course_id, module_number, raw.get("label"))
        _add_node(
            graph,
            node_id=node_id,
            node_type="mlo",
            label=str(raw.get("label")),
            text=str(raw.get("text") or ""),
            source="canvas",
            source_file=f"pages/{overview_slug}" if overview_slug else "canvas",
            source_ref=f"pages/{overview_slug}" if overview_slug else "canvas",
            canvas_url=overview_url,
            metadata={
                "module_number": module_number,
                "module_title": module_title,
                "confidence_extraction": raw.get("confidence", "low"),
            },
        )
        added_ids.append(node_id)
    return added_ids


def build_graph_with_canvas_data(
    config: dict[str, Any],
    course_config: dict[str, Any] | None = None,
    *,
    course_tree: list[dict[str, Any]] | None = None,
    syllabus_body: str | None = None,
    page_body_loader: Any = None,
) -> dict[str, Any]:
    """Build graph from Canvas (always) + local config (optional planning-map).

    Canvas is the source of truth. ``course_config`` is treated as a planning
    map that wins on label collisions but does not block extraction when
    absent. CLO/MLO text divergence between Canvas and config emits an
    ``AMBIGUOUS`` ``needs_review`` edge so the conflict surfaces without
    silently overwriting either side.

    This function must remain read-only. It uses existing Canvas retrieval
    helpers and never calls create/update/delete helpers.
    """
    course_config = course_config or {}
    course_id = _course_id_from_config(course_config, config.get("course_id"))
    graph = build_graph_from_course_config(course_config, course_id=course_id)
    graph["source_mode"] = "local-course-config+canvas-readonly" if course_config else "canvas-readonly"

    import canvas_api  # Local import keeps local-only mode free of Canvas deps.
    from course_navigator import fetch_course_tree

    domain = str(config.get("domain") or "canvas.asu.edu")

    # Phase 1 — Canvas-first CLO extraction from syllabus_body.
    if syllabus_body is None:
        try:
            import requests  # Already a project dependency; not imported globally to keep test isolation clean.

            response = requests.get(
                f"{config['course_url']}?include[]=syllabus_body",
                headers=config.get("headers", {}),
                timeout=30,
            )
            response.raise_for_status()
            syllabus_body = (response.json() or {}).get("syllabus_body") or ""
        except Exception as exc:
            # Surface the failure so the user sees that Canvas extraction was attempted
            # but did not yield a syllabus body. Returning '' silently would hide the
            # cause of an empty graph.
            print(
                f"WARNING: failed to fetch syllabus_body for course {course_id}: {exc}",
                file=sys.stderr,
            )
            syllabus_body = ""
    canvas_clos = extract_clos_from_syllabus(syllabus_body)
    if canvas_clos:
        _add_canvas_clo_nodes_and_edges(graph, course_id, domain, canvas_clos)

    tree = course_tree if course_tree is not None else fetch_course_tree(config)
    assignment_lookup = _lookup_by_id(canvas_api.list_assignments(config) or [])
    quiz_lookup = _lookup_by_id(canvas_api.list_quizzes(config) or [])
    discussion_lookup = _lookup_by_id(canvas_api.list_discussions(config) or [])

    # Phase 1 — Canvas-first MLO extraction from each module overview page.
    def _default_page_loader(slug: str) -> str:
        try:
            page = canvas_api.get_page(config, slug) or {}
            return page.get("body") or ""
        except Exception as exc:
            print(
                f"WARNING: failed to fetch overview page '{slug}': {exc}",
                file=sys.stderr,
            )
            return ""

    loader = page_body_loader or _default_page_loader
    for module_index_pre, module_pre in enumerate(tree, start=1):
        mod_num = _module_number(module_pre, module_index_pre)
        # Skip Module 0 (Welcome / Start Here) — no instructional MLOs.
        if mod_num == 0:
            continue
        mod_title = str(module_pre.get("name") or module_pre.get("title") or f"Module {mod_num}")
        overview_slug = _find_module_overview_slug(module_pre.get("items", []))
        if not overview_slug:
            continue
        page_body = loader(overview_slug)
        canvas_mlos = extract_mlos_from_overview(page_body, mod_num)
        if canvas_mlos:
            _add_canvas_mlo_nodes_and_edges(
                graph, course_id, domain, mod_num, mod_title, overview_slug, canvas_mlos,
            )

    mlos_by_module = _mlo_ids_by_module(graph)
    local_index = _local_node_index(graph)

    for module_index, module in enumerate(tree, start=1):
        module_number = _module_number(module, module_index)
        module_title = str(module.get("name") or module.get("title") or f"Module {module_number}")
        # Skip Module 0 (Welcome / Start Here) — no MLOs, only admin/onboarding.
        if module_number == 0:
            continue
        # Skip non-numbered admin/support modules (e.g. "Links and Tools for
        # Students", "ASU Online Facilitation Guide", "Course Evaluation",
        # "Orientation and Onboarding Review"). These hold student-support
        # tools (Slack, Zoom, AI Tutor links) and facilitator docs, not
        # instructional materials, so their items pollute the alignment graph.
        if not _MODULE_NAME_NUMBER_RE.search(module_title):
            continue
        target_mlos = mlos_by_module.get(module_number, [])

        for item_index, item in enumerate(module.get("items", []), start=1):
            item_type = str(item.get("type") or "")
            title = str(item.get("title") or item_type or f"Item {item_index}")
            content_id = item.get("content_id")
            source_ref = f"canvas.modules[{module_index - 1}].items[{item_index - 1}]"

            if item_type == "Page":
                # Skip non-instructional pages (Overview, Task List, admin,
                # instructor-only, support, mockup chrome, placeholders).
                # See _NON_INSTRUCTIONAL_PAGE_TITLE_RE for the full pattern.
                if not _is_instructional_page_title(title):
                    continue
                page_slug = str(item.get("page_url") or content_id or "")
                node_id = _stable_id("canvas-page", course_id, page_slug or item.get("id") or title)
                canvas_url = item.get("html_url") or _canvas_item_url(config, "Page", page_slug=page_slug)
                text = title
                node = _add_node(
                    graph,
                    node_id=node_id,
                    node_type="page",
                    label=title,
                    text=text,
                    source="canvas",
                    source_file="canvas",
                    source_ref=source_ref,
                    canvas_url=canvas_url,
                    metadata={
                        "module_number": module_number,
                        "module_title": module_title,
                        "canvas_id": item.get("id"),
                        "page_slug": page_slug,
                    },
                )
                _mark_config_canvas_conflict(graph, canvas_node=node, local_index=local_index, source_ref=source_ref)
                _add_inferred_module_edges(
                    graph,
                    node_id=node_id,
                    label=title,
                    module_number=module_number,
                    target_mlos=target_mlos,
                    edge_type="supports",
                    source_ref=f"{source_ref}.module_mlos",
                )
                continue

            if item_type == "Assignment":
                assignment = assignment_lookup.get(str(content_id)) or {}
                text = _plain_text(assignment.get("description") or item.get("title"))
                node_id = _stable_id("canvas-assignment", course_id, content_id or item.get("id") or title)
                canvas_url = assignment.get("html_url") or item.get("html_url") or _canvas_item_url(config, "Assignment", content_id)
                node = _add_node(
                    graph,
                    node_id=node_id,
                    node_type="assignment",
                    label=str(assignment.get("name") or title),
                    text=text,
                    source="canvas",
                    source_file="canvas",
                    source_ref=source_ref,
                    canvas_url=canvas_url,
                    metadata={
                        "module_number": module_number,
                        "module_title": module_title,
                        "canvas_id": content_id,
                        "points_possible": assignment.get("points_possible"),
                    },
                )
                _mark_config_canvas_conflict(graph, canvas_node=node, local_index=local_index, source_ref=source_ref)
                _add_inferred_module_edges(
                    graph,
                    node_id=node_id,
                    label=node["label"],
                    module_number=module_number,
                    target_mlos=target_mlos,
                    edge_type="assesses",
                    source_ref=f"{source_ref}.module_mlos",
                )
                for criterion_index, criterion in enumerate(assignment.get("rubric") or [], start=1):
                    criterion_label = _label_from_item(criterion, f"Criterion {criterion_index}")
                    criterion_text = _text_from_item(criterion)
                    criterion_id = _stable_id("canvas-rubric-criterion", course_id, content_id, criterion.get("id") or criterion_index)
                    _add_node(
                        graph,
                        node_id=criterion_id,
                        node_type="rubric_criterion",
                        label=criterion_label,
                        text=criterion_text,
                        source="canvas",
                        source_file="canvas",
                        source_ref=f"{source_ref}.rubric[{criterion_index - 1}]",
                        canvas_url=canvas_url,
                        metadata={
                            "module_number": module_number,
                            "module_title": module_title,
                            "assessment_id": node_id,
                            "canvas_id": criterion.get("id"),
                        },
                    )
                    _add_edge(
                        graph,
                        edge_type="contains",
                        source=node_id,
                        target=criterion_id,
                        confidence="EXTRACTED",
                        evidence=f"Canvas assignment '{node['label']}' includes rubric criterion '{criterion_label}'.",
                        source_ref=f"{source_ref}.rubric[{criterion_index - 1}]",
                    )
                    _add_inferred_module_edges(
                        graph,
                        node_id=criterion_id,
                        label=criterion_label,
                        module_number=module_number,
                        target_mlos=target_mlos,
                        edge_type="assesses",
                        source_ref=f"{source_ref}.rubric[{criterion_index - 1}].module_mlos",
                    )
                continue

            if item_type == "Quiz":
                quiz = quiz_lookup.get(str(content_id)) or {}
                node_id = _stable_id("canvas-quiz", course_id, content_id or item.get("id") or title)
                canvas_url = quiz.get("html_url") or item.get("html_url") or _canvas_item_url(config, "Quiz", content_id)
                label = str(quiz.get("title") or title)
                node = _add_node(
                    graph,
                    node_id=node_id,
                    node_type="quiz",
                    label=label,
                    text=_plain_text(quiz.get("description") or label),
                    source="canvas",
                    source_file="canvas",
                    source_ref=source_ref,
                    canvas_url=canvas_url,
                    metadata={
                        "module_number": module_number,
                        "module_title": module_title,
                        "canvas_id": content_id,
                        "points_possible": quiz.get("points_possible"),
                    },
                )
                _mark_config_canvas_conflict(graph, canvas_node=node, local_index=local_index, source_ref=source_ref)
                _add_inferred_module_edges(
                    graph,
                    node_id=node_id,
                    label=label,
                    module_number=module_number,
                    target_mlos=target_mlos,
                    edge_type="assesses",
                    source_ref=f"{source_ref}.module_mlos",
                )
                for question_index, question in enumerate(canvas_api.get_quiz_questions(config, content_id) or [], start=1):
                    question_label = _label_from_item(question, f"Question {question_index}")
                    question_text = _plain_text(question.get("question_text") or question.get("text") or question_label)
                    question_id = _stable_id("canvas-quiz-question", course_id, content_id, question.get("id") or question_index)
                    _add_node(
                        graph,
                        node_id=question_id,
                        node_type="quiz_question",
                        label=question_label,
                        text=question_text,
                        source="canvas",
                        source_file="canvas",
                        source_ref=f"{source_ref}.questions[{question_index - 1}]",
                        canvas_url=canvas_url,
                        metadata={
                            "module_number": module_number,
                            "module_title": module_title,
                            "assessment_id": node_id,
                            "canvas_id": question.get("id"),
                            "question_type": question.get("question_type"),
                        },
                    )
                    _add_edge(
                        graph,
                        edge_type="contains",
                        source=node_id,
                        target=question_id,
                        confidence="EXTRACTED",
                        evidence=f"Canvas quiz '{label}' contains question '{question_label}'.",
                        source_ref=f"{source_ref}.questions[{question_index - 1}]",
                    )
                    _add_edge(
                        graph,
                        edge_type="needs_review",
                        source=question_id,
                        target=node_id,
                        confidence="AMBIGUOUS",
                        evidence=f"Canvas quiz question '{question_label}' needs MLO confirmation.",
                        source_ref=f"{source_ref}.questions[{question_index - 1}]",
                    )
                continue

            if item_type == "Discussion":
                discussion = discussion_lookup.get(str(content_id)) or {}
                node_id = _stable_id("canvas-discussion", course_id, content_id or item.get("id") or title)
                canvas_url = discussion.get("html_url") or item.get("html_url") or _canvas_item_url(config, "Discussion", content_id)
                label = str(discussion.get("title") or title)
                node = _add_node(
                    graph,
                    node_id=node_id,
                    node_type="discussion",
                    label=label,
                    text=_plain_text(discussion.get("message") or label),
                    source="canvas",
                    source_file="canvas",
                    source_ref=source_ref,
                    canvas_url=canvas_url,
                    metadata={
                        "module_number": module_number,
                        "module_title": module_title,
                        "canvas_id": content_id,
                        "points_possible": discussion.get("points_possible"),
                    },
                )
                _mark_config_canvas_conflict(graph, canvas_node=node, local_index=local_index, source_ref=source_ref)
                _add_inferred_module_edges(
                    graph,
                    node_id=node_id,
                    label=label,
                    module_number=module_number,
                    target_mlos=target_mlos,
                    edge_type="assesses",
                    source_ref=f"{source_ref}.module_mlos",
                )
                continue

            if item_type in {"File", "ExternalUrl", "ExternalTool"}:
                node_type = "reading" if item_type in {"File", "ExternalUrl"} else "page"
                node_id = _stable_id(f"canvas-{node_type}", course_id, item.get("id") or title)
                canvas_url = item.get("html_url") or item.get("external_url") or _canvas_course_url(config)
                _add_node(
                    graph,
                    node_id=node_id,
                    node_type=node_type,
                    label=title,
                    text=title,
                    source="canvas",
                    source_file="canvas",
                    source_ref=source_ref,
                    canvas_url=canvas_url,
                    metadata={
                        "module_number": module_number,
                        "module_title": module_title,
                        "canvas_id": item.get("id"),
                        "canvas_type": item_type,
                    },
                )
                _add_inferred_module_edges(
                    graph,
                    node_id=node_id,
                    label=title,
                    module_number=module_number,
                    target_mlos=target_mlos,
                    edge_type="supports",
                    source_ref=f"{source_ref}.module_mlos",
                )

    graph["gaps"] = _analyze_preview_gaps(graph)
    graph["validation_issues"] = validate_graph_schema(graph)
    return graph


def validate_graph_schema(graph: dict[str, Any]) -> list[dict[str, str]]:
    issues: list[dict[str, str]] = []
    if graph.get("schema_version") != SCHEMA_VERSION:
        issues.append({"level": "error", "message": f"Unsupported schema_version: {graph.get('schema_version')}"})

    nodes = graph.get("nodes")
    edges = graph.get("edges")
    if not isinstance(nodes, list):
        issues.append({"level": "error", "message": "Graph nodes must be a list."})
        nodes = []
    if not isinstance(edges, list):
        issues.append({"level": "error", "message": "Graph edges must be a list."})
        edges = []

    node_ids: set[str] = set()
    for node in nodes:
        node_id = str(node.get("id") or "")
        node_type = node.get("type")
        if not node_id:
            issues.append({"level": "error", "message": "Node is missing id."})
            continue
        if node_id in node_ids:
            issues.append({"level": "error", "message": f"Duplicate node id: {node_id}", "node_id": node_id})
        node_ids.add(node_id)
        if node_type not in NODE_TYPES:
            issues.append({"level": "error", "message": f"Invalid node type: {node_type}", "node_id": node_id})
        if not node.get("label"):
            issues.append({"level": "warning", "message": f"Node has no label: {node_id}", "node_id": node_id})

    edge_ids: set[str] = set()
    for edge in edges:
        edge_id = str(edge.get("id") or "")
        edge_type = edge.get("type")
        if not edge_id:
            issues.append({"level": "error", "message": "Edge is missing id."})
            continue
        if edge_id in edge_ids:
            issues.append({"level": "error", "message": f"Duplicate edge id: {edge_id}", "edge_id": edge_id})
        edge_ids.add(edge_id)
        if edge_type not in EDGE_TYPES:
            issues.append({"level": "error", "message": f"Invalid edge type: {edge_type}", "edge_id": edge_id})
        if edge.get("source") not in node_ids:
            issues.append({"level": "error", "message": f"Edge source not found: {edge.get('source')}", "edge_id": edge_id})
        if edge.get("target") not in node_ids:
            issues.append({"level": "error", "message": f"Edge target not found: {edge.get('target')}", "edge_id": edge_id})
        if edge.get("confidence") not in CONFIDENCE_VALUES:
            issues.append({"level": "error", "message": f"Invalid edge confidence: {edge.get('confidence')}", "edge_id": edge_id})
        if not str(edge.get("evidence") or "").strip():
            issues.append({"level": "error", "message": f"Edge is missing evidence: {edge_id}", "edge_id": edge_id})

    return issues


def default_config_path(course_id: str) -> Path:
    return PLUGIN_ROOT / "courses" / str(course_id) / "course-config.json"


def default_inferred_mappings_path(course_id: str) -> Path:
    """Sidecar holding chat-derived CLO/MLO/assessment mappings.

    Separate from `course-config.json` so the canonical design doc stays
    pristine. Inferences persist across chat sessions to avoid drift, but
    never overwrite faculty-declared canonical fields. Promotion happens
    only on explicit user approval ("alignment approved" / "faculty
    approved" / "align approved").
    """
    return PLUGIN_ROOT / "courses" / str(course_id) / "alignment" / "inferred_mappings.json"


# Schema version for the chat-inferred mappings sidecar. SKILL.md
# documents the minimum JSON shape so the chat-to-file handoff is reliable.
INFERRED_MAPPINGS_SCHEMA_VERSION = "alignment-inferred-mappings/v1"


def load_inferred_mappings(path: Path) -> dict[str, Any] | None:
    """Load the chat-derived mappings sidecar, returning None when absent.

    Raises ValueError on malformed JSON or schema mismatch so the build
    fails fast instead of silently rendering a partial graph.
    """
    if not path.exists():
        return None
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise ValueError(
            f"inferred_mappings.json at {path} is malformed: {exc}"
        ) from exc
    if not isinstance(payload, dict):
        raise ValueError(
            f"inferred_mappings.json at {path} must be a JSON object, got {type(payload).__name__}"
        )
    schema = payload.get("schema_version")
    if schema and schema != INFERRED_MAPPINGS_SCHEMA_VERSION:
        raise ValueError(
            f"inferred_mappings.json at {path} declares schema_version={schema}; "
            f"expected {INFERRED_MAPPINGS_SCHEMA_VERSION}"
        )
    return payload


def merge_inferred_into_course_config(
    course_config: dict[str, Any] | None,
    inferred: dict[str, Any] | None,
) -> dict[str, Any]:
    """Layer chat-derived MLO→CLO + assessment→MLO mappings onto a course_config dict.

    Canonical declared fields (existing `modules[].objectives[].clo` /
    `ladders_to` / `mlo_ids`) are preserved verbatim. Inferences only
    fill gaps — they never overwrite a declared canonical mapping.
    Returns a deep-merged copy; the original course_config is untouched.
    """
    import copy

    merged = copy.deepcopy(course_config) if course_config else {"modules": []}
    if not inferred:
        return merged

    # ---- MLO -> CLO ladders ----
    mlo_to_clo_records = inferred.get("mlo_to_clo") or []
    # Build a lookup by MLO id for fast match across the existing modules tree.
    mlo_proposals = {
        str(rec.get("mlo_id") or rec.get("mlo_label")): rec
        for rec in mlo_to_clo_records
        if (rec.get("mlo_id") or rec.get("mlo_label"))
    }
    for module in merged.get("modules", []) or []:
        if not isinstance(module, dict):
            continue
        # The course-config schema uses `modules[].objectives[]` or
        # `modules[].mlos_draft[]` for module-level outcomes.
        for field in ("objectives", "mlos_draft", "mlos", "module_learning_objectives"):
            for mlo in _ensure_list(module.get(field)):
                if not isinstance(mlo, dict):
                    continue
                # Identify by id or label.
                key_candidates = [str(mlo.get("id")), str(mlo.get("label"))]
                proposal = next(
                    (mlo_proposals[k] for k in key_candidates if k and k in mlo_proposals),
                    None,
                )
                if not proposal:
                    continue
                # Only fill when no canonical declaration exists.
                existing_refs, _ = _refs_from_item(mlo, ("clo_ids", "clos", "clo", "ladders_to"))
                if existing_refs:
                    continue
                proposed_clos = proposal.get("proposed_clo_ids") or proposal.get("clo_ids") or []
                if proposed_clos:
                    mlo["ladders_to"] = list(proposed_clos)

    # ---- Assessment -> MLO mappings ----
    assessment_records = inferred.get("assessment_to_mlo") or []
    # Lookup by assessment id (Canvas-derived) or by (module_number, label).
    by_id = {
        str(rec.get("assessment_id")): rec
        for rec in assessment_records
        if rec.get("assessment_id")
    }
    by_module_label = {
        (rec.get("module_number"), _normalize_ref(rec.get("assessment_label"))): rec
        for rec in assessment_records
        if rec.get("module_number") and rec.get("assessment_label")
    }
    for module_index, module in enumerate(merged.get("modules", []) or [], start=1):
        if not isinstance(module, dict):
            continue
        mod_num = module.get("number") or module.get("module_number") or module_index
        for spec in _iter_assessment_specs(module):
            raw = spec.get("raw") or {}
            if not isinstance(raw, dict):
                continue
            existing_refs, _ = _refs_from_item(raw, ("mlo_ids", "mlos", "mlo", "ladders_to", "aligns_to"))
            if existing_refs:
                continue
            proposal = None
            canvas_id = str(raw.get("canvas_id") or raw.get("id") or "")
            if canvas_id and canvas_id in by_id:
                proposal = by_id[canvas_id]
            else:
                key = (mod_num, _normalize_ref(raw.get("title") or raw.get("label") or raw.get("name")))
                if key in by_module_label:
                    proposal = by_module_label[key]
            if not proposal:
                continue
            proposed_mlos = proposal.get("proposed_mlo_ids") or proposal.get("mlo_ids") or []
            if proposed_mlos:
                raw["mlo_ids"] = list(proposed_mlos)

    return merged


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


# ---------------------------------------------------------------------------
# Inferred mappings sidecar (PR D v7.17.0)
# ---------------------------------------------------------------------------

INFERRED_MAPPINGS_SCHEMA_VERSION = "alignment-inferred-mappings/v1"


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


def load_inferred_mappings(path: Path) -> dict[str, Any]:
    """Read and shallow-validate an inferred_mappings.json sidecar.

    Raises ValueError on malformed JSON, missing required keys, or wrong
    schema_version. Sidecar shape (required):
        {
          "schema_version": "alignment-inferred-mappings/v1",
          "course_id": "...",
          "mlo_to_clo": [ {"mlo_id": "...", "proposed_clo_ids": [...], "rationale": "..."} ],
          "assessment_to_mlo": [ {"assessment_id": "...", "proposed_mlo_ids": [...], "rationale": "..."} ]
        }
    """
    if not path.exists():
        return None
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise ValueError(f"malformed JSON at {path}: {exc}") from exc
    except OSError as exc:
        raise ValueError(f"unreadable JSON at {path}: {exc}") from exc
    if not isinstance(data, dict):
        raise ValueError(f"{path}: top-level must be an object")
    sv = data.get("schema_version")
    if sv != INFERRED_MAPPINGS_SCHEMA_VERSION:
        raise ValueError(
            f"{path}: schema_version '{sv}' != expected '{INFERRED_MAPPINGS_SCHEMA_VERSION}'"
        )
    for key in ("mlo_to_clo", "assessment_to_mlo"):
        if key in data and not isinstance(data[key], list):
            raise ValueError(f"{path}: '{key}' must be a list")
    return data


def merge_inferred_into_course_config(
    course_config: dict[str, Any] | None,
    inferred: dict[str, Any] | None,
) -> dict[str, Any]:
    """Layer chat-inferred mappings onto course_config without overwriting canonical.

    Canonical declared fields (`modules[].objectives[].clo`,
    `modules[].assessments[].mlo_ids`, etc.) always win. Inferences fill in
    `ladders_to` on MLOs that have no declared CLO ref and `mlo_ids` on
    assessments that have no declared MLO mapping.

    Returns a deep-copied config dict so the caller's input is not mutated.
    """
    import copy

    base = copy.deepcopy(course_config) if course_config else {}
    if not inferred:
        return base

    # Index inferred maps by id + label for fuzzy lookup.
    mlo_map: dict[str, list[str]] = {}
    for entry in inferred.get("mlo_to_clo", []) or []:
        if not isinstance(entry, dict):
            continue
        clo_ids = entry.get("proposed_clo_ids") or []
        if not clo_ids:
            continue
        for key in (entry.get("mlo_id"), entry.get("mlo_label")):
            if key:
                mlo_map[_normalize_ref(key)] = list(clo_ids)
    asn_map: dict[str, list[str]] = {}
    for entry in inferred.get("assessment_to_mlo", []) or []:
        if not isinstance(entry, dict):
            continue
        mlo_ids = entry.get("proposed_mlo_ids") or []
        if not mlo_ids:
            continue
        for key in (entry.get("assessment_id"), entry.get("assessment_label")):
            if key:
                asn_map[_normalize_ref(key)] = list(mlo_ids)

    modules = base.setdefault("modules", [])
    for module in modules:
        if not isinstance(module, dict):
            continue
        # MLO ladder filling.
        for field in ("mlos_draft", "objectives", "mlos", "module_learning_objectives", "learning_objectives"):
            for raw in _ensure_list(module.get(field)) or []:
                if not isinstance(raw, dict):
                    continue
                already, _ = _refs_from_item(raw, ("clo_ids", "clos", "clo", "ladders_to"))
                if already:
                    continue
                for key in (raw.get("id"), raw.get("label"), raw.get("text")):
                    proposed = mlo_map.get(_normalize_ref(key)) if key else None
                    if proposed:
                        raw["ladders_to"] = list(proposed)
                        break
        # Assessment MLO filling.
        assessments = module.get("assessments")
        candidates: list[dict[str, Any]] = []
        if isinstance(assessments, list):
            candidates = [a for a in assessments if isinstance(a, dict)]
        elif isinstance(assessments, dict):
            candidates = [a for a in assessments.values() if isinstance(a, dict)]
        for raw in candidates:
            already, _ = _refs_from_item(raw, ("mlo_ids", "mlos", "mlo", "aligns_to"))
            if already:
                continue
            for key in (raw.get("id"), raw.get("canvas_id"), raw.get("title"), raw.get("name")):
                proposed = asn_map.get(_normalize_ref(key)) if key else None
                if proposed:
                    raw["mlo_ids"] = list(proposed)
                    break
    return base


def apply_inferred_mappings_to_graph(
    graph: dict[str, Any],
    inferred: dict[str, Any] | None,
) -> int:
    """Layer chat-derived inferred mappings onto a built graph as INFERRED edges.

    Scans graph nodes by label (CLO/MLO/assessment) and emits new edges for
    each proposed mapping where both endpoints exist. Skips when a canonical
    EXTRACTED edge already covers the same source→target pair (canonical
    always wins).

    Returns the number of edges added.
    """
    if not inferred:
        return 0
    nodes = graph.get("nodes", [])
    # Build label → node lookup per type.
    clo_by_label: dict[str, dict[str, Any]] = {}
    mlo_by_label: dict[str, dict[str, Any]] = {}
    assessment_by_key: dict[str, dict[str, Any]] = {}
    for n in nodes:
        ntype = n.get("type")
        label_norm = _normalize_ref(n.get("label"))
        if ntype == "clo":
            clo_by_label[label_norm] = n
        elif ntype == "mlo":
            mlo_by_label[label_norm] = n
        elif ntype in {"assignment", "discussion", "quiz", "quiz_question", "rubric_criterion"}:
            for key in (
                n.get("id"),
                (n.get("metadata") or {}).get("canvas_id"),
                n.get("label"),
            ):
                if key:
                    assessment_by_key[_normalize_ref(key)] = n

    existing_pairs = {
        (e.get("source"), e.get("target"), e.get("type"))
        for e in graph.get("edges", [])
    }
    added = 0

    for entry in inferred.get("mlo_to_clo", []) or []:
        if not isinstance(entry, dict):
            continue
        mlo_node = mlo_by_label.get(_normalize_ref(entry.get("mlo_id") or entry.get("mlo_label")))
        if not mlo_node:
            continue
        for clo_ref in entry.get("proposed_clo_ids") or []:
            clo_node = clo_by_label.get(_normalize_ref(clo_ref))
            if not clo_node:
                continue
            key = (mlo_node["id"], clo_node["id"], "aligns_to")
            if key in existing_pairs:
                continue
            _add_edge(
                graph,
                edge_type="aligns_to",
                source=mlo_node["id"],
                target=clo_node["id"],
                confidence="INFERRED",
                evidence=str(entry.get("rationale") or "Chat-inferred ladder from inferred_mappings.json sidecar."),
                source_ref="inferred_mappings.json",
            )
            existing_pairs.add(key)
            added += 1

    for entry in inferred.get("assessment_to_mlo", []) or []:
        if not isinstance(entry, dict):
            continue
        asn_node = None
        for lookup in (
            entry.get("assessment_id"),
            entry.get("canvas_id"),
            entry.get("assessment_label"),
        ):
            if not lookup:
                continue
            asn_node = assessment_by_key.get(_normalize_ref(lookup))
            if asn_node:
                break
        if not asn_node:
            continue
        for mlo_ref in entry.get("proposed_mlo_ids") or []:
            mlo_node = mlo_by_label.get(_normalize_ref(mlo_ref))
            if not mlo_node:
                continue
            edge_type = "aligns_to" if asn_node.get("type") in {"quiz_question", "rubric_criterion"} else "assesses"
            key = (asn_node["id"], mlo_node["id"], edge_type)
            if key in existing_pairs:
                continue
            _add_edge(
                graph,
                edge_type=edge_type,
                source=asn_node["id"],
                target=mlo_node["id"],
                confidence="INFERRED",
                evidence=str(entry.get("rationale") or "Chat-inferred mapping from inferred_mappings.json sidecar."),
                source_ref="inferred_mappings.json",
            )
            existing_pairs.add(key)
            added += 1

    # Re-run gap analysis since edges changed.
    if added:
        graph["gaps"] = _analyze_preview_gaps(graph)
    return added


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


def write_graph(graph: dict[str, Any], path: Path) -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(graph, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    return path


def _summary(graph: dict[str, Any], output_path: Path | None = None) -> dict[str, Any]:
    errors = [issue for issue in graph.get("validation_issues", []) if issue.get("level") == "error"]
    return {
        "ok": not errors,
        "course_id": graph.get("course_id"),
        "output_path": str(output_path) if output_path else None,
        "nodes": len(graph.get("nodes", [])),
        "edges": len(graph.get("edges", [])),
        "gaps": {key: len(value) for key, value in graph.get("gaps", {}).items()},
        "errors": len(errors),
        "warnings": len(graph.get("validation_issues", [])) - len(errors),
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Build local alignment preview graph.json from course-config.json.")
    parser.add_argument("--course-id", help="Course ID. Defaults to course.id in the config when --config is provided.")
    parser.add_argument("--config", help="Path to course-config.json. Defaults to courses/{course-id}/course-config.json.")
    parser.add_argument("--output", help="Path for graph.json. Defaults to courses/{course-id}/alignment/graph.json.")
    parser.add_argument(
        "--include-canvas",
        action="store_true",
        help=(
            "Add read-only Canvas extraction (CLOs from syllabus, MLOs from module overview pages, "
            "module items, assignments, quizzes, quiz questions, rubrics). Canvas is the source of "
            "truth; course-config.json becomes an optional planning-map override that wins on text "
            "conflicts. When --include-canvas is set, --config is optional."
        ),
    )
    parser.add_argument("--validate-only", action="store_true", help="Validate an existing graph JSON instead of building one.")
    args = parser.parse_args(argv)

    if args.validate_only:
        if not args.output:
            parser.error("--validate-only requires --output")
        graph_path = Path(args.output)
        graph = json.loads(graph_path.read_text(encoding="utf-8"))
        issues = validate_graph_schema(graph)
        print(json.dumps({"ok": not any(i["level"] == "error" for i in issues), "issues": issues}, indent=2))
        return 0 if not any(i["level"] == "error" for i in issues) else 1

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

    config_path = Path(args.config) if args.config else default_config_path(str(args.course_id))
    course_config: dict[str, Any] = {}
    if config_path.exists():
        try:
            course_config = load_course_config(config_path)
        except (json.JSONDecodeError, OSError) as exc:
            if args.include_canvas:
                # Partial / malformed config: fall back to Canvas-only extraction.
                # Phase 1 acceptance: build must continue when config is unreadable
                # AND --include-canvas is set.
                print(f"WARNING: course-config.json unreadable ({exc}); proceeding with Canvas-only extraction.")
                course_config = {}
            else:
                parser.error(f"Unable to read course-config.json: {exc}")
    elif not args.include_canvas:
        # Local-only mode still needs CLOs/MLOs from somewhere.
        parser.error(
            f"course-config.json not found at {config_path}. "
            "Provide --config, or pass --include-canvas to extract CLOs/MLOs from Canvas."
        )

    course_id = _course_id_from_config(course_config, args.course_id)
    output_path = Path(args.output) if args.output else default_graph_path(course_id)
    if args.include_canvas:
        from canvas_api import get_config

        try:
            canvas_config = get_config(course_id=course_id)
        except Exception as exc:
            print(f"ERROR: unable to resolve Canvas course '{course_id}': {exc}")
            return 1
        graph = build_graph_with_canvas_data(canvas_config, course_config)
    else:
        graph = build_graph_from_course_config(course_config, course_id=course_id)
    write_graph(graph, output_path)
    summary = _summary(graph, output_path)
    print(json.dumps(summary, indent=2))
    # Surface a clear warning when --include-canvas yielded zero CLOs AND zero MLOs.
    # The build itself still succeeds (a zero-graph is valid output for a brand-new
    # course shell), but the user needs to know Canvas extraction produced no
    # objective evidence — otherwise the empty preview looks like a bug.
    if args.include_canvas and not course_config:
        clo_count = sum(1 for n in graph.get("nodes", []) if n.get("type") == "clo")
        mlo_count = sum(1 for n in graph.get("nodes", []) if n.get("type") == "mlo")
        if clo_count == 0 and mlo_count == 0:
            print(
                "WARNING: Canvas extraction produced 0 CLOs and 0 MLOs. "
                "Verify the syllabus body contains a 'Students will be able to' bullet list "
                "and module overview pages contain 'Learning Objectives' bullet lists.",
                file=sys.stderr,
            )
    return 0 if not any(i["level"] == "error" for i in graph.get("validation_issues", [])) else 1


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