#!/usr/bin/env python3
"""Generate faculty-facing mockup decision labels from an alignment graph.

This is PR 5 of the alignment preview plan. It reads
``courses/{id}/alignment/graph.json`` and emits
``courses/{id}/alignment/mockup_decisions.json`` with per-MLO and per-item
KEEP / ADD / REPLACE / OPTIONAL decision labels plus faculty-safe rationale
text. Quizzes are the primary target; assignments and discussions follow.

This artifact is read by ``course-mockup`` skill workflows when generating
quiz / assignment / discussion mockups. It is read-only with respect to
the alignment graph and never modifies Canvas content.

Per ``skills/alignment/SKILL.md`` Invocation Gate, this script must be
invoked explicitly by the user or after explicit confirmation. It does not
auto-run as a side effect of other workflows.
"""

from __future__ import annotations

import argparse
import json
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

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

DECISION_LABELS = {"KEEP", "ADD", "REPLACE", "OPTIONAL"}
CONFIDENCE_TO_DECISION = {
    "EXTRACTED": "KEEP",
    "INFERRED": "OPTIONAL",
    "AMBIGUOUS": "REPLACE",
}

# Faculty-safe wording. Refuses "bad alignment" / "failed assessment" framing
# per ``skills/course-mockup/SKILL.md`` Faculty-Facing Framing rule.
RATIONALE_TEMPLATES = {
    "KEEP": (
        "{item_label} keeps explicit MLO evidence for {mlo_label}. "
        "Evidence: {evidence}"
    ),
    "OPTIONAL": (
        "{item_label} appears to map to {mlo_label} through module context "
        "rather than explicit alignment. The current item is defensible — "
        "faculty may keep it, revise the wording, or replace with an "
        "example that gathers stronger evidence."
    ),
    "REPLACE": (
        "{item_label} has an ambiguous link to {mlo_label}. Consider "
        "revising or replacing the item with one that targets the skill "
        "more directly. The instructor's disciplinary judgment decides "
        "the final wording."
    ),
    "ADD": (
        "{mlo_label} has no assessment evidence in the current graph. "
        "Consider adding a quiz question, rubric criterion, assignment "
        "prompt, or discussion prompt that gathers evidence for this "
        "objective. The instructor decides whether the suggestion fits "
        "the module's intent."
    ),
}

MLO_OVERALL_RATIONALE = {
    "KEEP": (
        "{mlo_label} has explicit assessment evidence in the current graph. "
        "The mapped items are defensible to keep as-is."
    ),
    "OPTIONAL": (
        "{mlo_label} has only inferred assessment evidence. The current "
        "items work, but a more direct question, prompt, or criterion may "
        "gather stronger evidence."
    ),
    "REPLACE": (
        "{mlo_label} has only ambiguous assessment evidence. Consider "
        "revising or replacing the mapped items to gather stronger evidence "
        "for this objective. The instructor's disciplinary judgment decides "
        "whether the current items stay or change."
    ),
    "ADD": (
        "{mlo_label} has no assessment evidence. Consider adding at least "
        "one quiz question, rubric criterion, assignment prompt, or "
        "discussion prompt aligned to this objective."
    ),
}

ASSESSMENT_NODE_TYPES = {"assignment", "discussion", "quiz"}
ASSESSMENT_PART_TYPES = {"quiz_question", "rubric_criterion"}
TARGET_NODE_TYPES = ASSESSMENT_NODE_TYPES | ASSESSMENT_PART_TYPES

# Default include-order: quizzes first per PR 16 spec; assignments + discussions follow.
DEFAULT_INCLUDE_TYPES = ("quiz_question", "quiz", "rubric_criterion", "assignment", "discussion")


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_index(graph: dict[str, Any]) -> dict[str, dict[str, Any]]:
    return {node["id"]: node for node in graph.get("nodes", []) if node.get("id")}


def _outgoing_index(graph: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
    out: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for edge in graph.get("edges", []):
        if edge.get("source"):
            out[edge["source"]].append(edge)
    return out


def _incoming_index(graph: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
    inc: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for edge in graph.get("edges", []):
        if edge.get("target"):
            inc[edge["target"]].append(edge)
    return inc


def _mlo_evidence_edges(
    mlo_id: str,
    incoming: dict[str, list[dict[str, Any]]],
    nodes: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
    """Return edges that constitute assessment evidence for an MLO.

    An evidence edge is an ``assesses`` or ``aligns_to`` edge whose source
    node is an assessment or assessment part. ``aligns_to`` is included so
    explicit MLO-mapped quiz_question / rubric_criterion items still
    register as evidence (the builder uses ``aligns_to`` for quiz questions
    with an explicit ``mlo_ids`` declaration).
    """
    evidence: list[dict[str, Any]] = []
    for edge in incoming.get(mlo_id, []):
        if edge.get("type") not in {"assesses", "aligns_to"}:
            continue
        source = nodes.get(edge.get("source"))
        if not source or source.get("type") not in TARGET_NODE_TYPES:
            continue
        evidence.append(edge)
    return evidence


def _mlo_overall_decision(evidence: list[dict[str, Any]]) -> str:
    if not evidence:
        return "ADD"
    confidences = {edge.get("confidence") for edge in evidence}
    if "EXTRACTED" in confidences:
        return "KEEP"
    if "INFERRED" in confidences:
        return "OPTIONAL"
    if "AMBIGUOUS" in confidences:
        return "REPLACE"
    return "REPLACE"


def _item_mlo_edges(
    item_id: str,
    outgoing: dict[str, list[dict[str, Any]]],
    nodes: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
    edges: list[dict[str, Any]] = []
    for edge in outgoing.get(item_id, []):
        if edge.get("type") not in {"assesses", "aligns_to"}:
            continue
        target = nodes.get(edge.get("target"))
        if not target or target.get("type") != "mlo":
            continue
        edges.append(edge)
    return edges


def _item_decision_for_edge(edge: dict[str, Any]) -> str:
    return CONFIDENCE_TO_DECISION.get(str(edge.get("confidence", "")), "REPLACE")


def _format_rationale(label: str, **fields: Any) -> str:
    template = RATIONALE_TEMPLATES[label]
    safe_fields = {key: _truncate(value, 240) for key, value in fields.items()}
    return template.format(**safe_fields)


def _format_mlo_rationale(label: str, mlo_label: str) -> str:
    return MLO_OVERALL_RATIONALE[label].format(mlo_label=mlo_label)


def _parent_assessment_id(
    node: dict[str, Any],
    incoming: dict[str, list[dict[str, Any]]],
    nodes: dict[str, dict[str, Any]],
) -> str | None:
    """Return the assessment node id that ``contains`` this part."""
    for edge in incoming.get(node["id"], []):
        if edge.get("type") != "contains":
            continue
        parent = nodes.get(edge.get("source"))
        if parent and parent.get("type") in ASSESSMENT_NODE_TYPES:
            return parent["id"]
    metadata = node.get("metadata") or {}
    return metadata.get("assessment_id")


def _evidence_snippet(edge: dict[str, Any]) -> str:
    return _truncate(edge.get("evidence") or "", 240)


def build_decisions(
    graph: dict[str, Any],
    include_types: Iterable[str] | None = None,
) -> dict[str, Any]:
    """Build the mockup_decisions artifact from a graph."""
    include = tuple(include_types) if include_types else DEFAULT_INCLUDE_TYPES
    nodes = _node_index(graph)
    outgoing = _outgoing_index(graph)
    incoming = _incoming_index(graph)

    decisions_by_mlo: list[dict[str, Any]] = []
    decisions_by_item: list[dict[str, Any]] = []
    summary: dict[str, int] = {label.lower(): 0 for label in DECISION_LABELS}

    # ---- Per-MLO decisions ----
    for node in graph.get("nodes", []):
        if node.get("type") != "mlo":
            continue
        evidence_edges = _mlo_evidence_edges(node["id"], incoming, nodes)
        overall_label = _mlo_overall_decision(evidence_edges)
        current_evidence: list[dict[str, Any]] = []
        for edge in evidence_edges:
            source = nodes.get(edge.get("source"), {})
            current_evidence.append({
                "item_id": source.get("id"),
                "item_type": source.get("type"),
                "item_label": source.get("label"),
                "item_text": _truncate(source.get("text"), 200),
                "confidence": edge.get("confidence"),
                "evidence": _evidence_snippet(edge),
                "edge_type": edge.get("type"),
            })
        entry = {
            "mlo_id": node["id"],
            "mlo_label": node.get("label"),
            "mlo_text": _truncate(node.get("text"), 240),
            "module_number": (node.get("metadata") or {}).get("module_number"),
            "overall_label": overall_label,
            "overall_rationale": _format_mlo_rationale(overall_label, str(node.get("label") or node["id"])),
            "current_evidence": current_evidence,
        }
        if overall_label == "ADD":
            entry["missing_evidence_note"] = (
                f"No quiz question, rubric criterion, assignment prompt, or "
                f"discussion prompt currently maps to {node.get('label') or node['id']}."
            )
        decisions_by_mlo.append(entry)
        summary[overall_label.lower()] += 1

    # ---- Per-item decisions ----
    for node in graph.get("nodes", []):
        node_type = node.get("type")
        if node_type not in TARGET_NODE_TYPES:
            continue
        if node_type not in include:
            continue
        item_edges = _item_mlo_edges(node["id"], outgoing, nodes)
        if not item_edges:
            # Orphaned item — no MLO mapping. Treat as REPLACE candidate.
            decision_label = "REPLACE"
            confidence = "AMBIGUOUS"
            rationale = (
                f"{node.get('label') or node['id']} has no explicit MLO mapping in "
                "the graph. The instructor may revise the item to target a stated "
                "objective or remove it if no objective applies."
            )
            decisions_by_item.append({
                "item_id": node["id"],
                "item_type": node_type,
                "item_label": node.get("label"),
                "item_text": _truncate(node.get("text"), 240),
                "parent_assessment_id": _parent_assessment_id(node, incoming, nodes),
                "decision_label": decision_label,
                "confidence": confidence,
                "rationale": rationale,
                "mapped_mlos": [],
            })
            summary[decision_label.lower()] += 1
            continue
        # One decision entry per (item, MLO) mapping.
        for edge in item_edges:
            target = nodes.get(edge.get("target"), {})
            decision_label = _item_decision_for_edge(edge)
            rationale = _format_rationale(
                decision_label,
                item_label=str(node.get("label") or node["id"]),
                mlo_label=str(target.get("label") or target.get("id") or "target MLO"),
                evidence=_evidence_snippet(edge),
            )
            decisions_by_item.append({
                "item_id": node["id"],
                "item_type": node_type,
                "item_label": node.get("label"),
                "item_text": _truncate(node.get("text"), 240),
                "parent_assessment_id": _parent_assessment_id(node, incoming, nodes),
                "decision_label": decision_label,
                "confidence": edge.get("confidence"),
                "rationale": rationale,
                "mapped_mlos": [{
                    "mlo_id": target.get("id"),
                    "mlo_label": target.get("label"),
                    "edge_type": edge.get("type"),
                    "confidence": edge.get("confidence"),
                    "evidence": _evidence_snippet(edge),
                }],
            })
            summary[decision_label.lower()] += 1

    return {
        "schema_version": SCHEMA_VERSION,
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "course_id": graph.get("course_id"),
        "course_title": graph.get("course_title"),
        "source_mode": graph.get("source_mode"),
        "graph_schema_version": graph.get("schema_version"),
        "summary": summary,
        "decisions_by_mlo": decisions_by_mlo,
        "decisions_by_item": decisions_by_item,
    }


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" / "mockup_decisions.json"


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


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


def _summary(decisions: dict[str, Any], output_path: Path | None) -> dict[str, Any]:
    return {
        "ok": True,
        "course_id": decisions.get("course_id"),
        "output_path": str(output_path) if output_path else None,
        "decisions_by_mlo": len(decisions.get("decisions_by_mlo", [])),
        "decisions_by_item": len(decisions.get("decisions_by_item", [])),
        "summary": decisions.get("summary", {}),
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Build alignment mockup decision labels (KEEP/ADD/REPLACE/OPTIONAL).",
    )
    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 mockup_decisions.json. Defaults to courses/{course-id}/alignment/mockup_decisions.json.")
    parser.add_argument(
        "--include-types",
        help="Comma-separated assessment node types to include. Defaults to all (quiz_question,quiz,rubric_criterion,assignment,discussion).",
    )
    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)
    include_types = None
    if args.include_types:
        include_types = tuple(item.strip() for item in args.include_types.split(",") if item.strip())
    decisions = build_decisions(graph, include_types=include_types)
    write_decisions(decisions, output_path)
    print(json.dumps(_summary(decisions, output_path), indent=2))
    return 0


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