#!/usr/bin/env python3
"""Run Mode B alignment preview end-to-end via a single command.

PR B of `docs/plan-alignment-mode-b-canvas-first-graphify.md`. The
`/alignment` skill invokes this orchestrator in response to plain-English
Mode B prompts like "visualize alignment for {course}" so IDs never see
multi-step bash chains. Each underlying script
(`alignment_graph_preview.py`, `alignment_preview_html.py`,
`alignment_mockup_decisions.py`) remains available as a standalone CLI
for finer-grained debugging.

Canvas is the source of truth. `course-config.json` is treated as an
optional planning-map override (config wins on label collision; text
divergence emits `AMBIGUOUS needs_review` edges). Read-only.
"""

from __future__ import annotations

import argparse
import html
import json
import platform
import re
import subprocess
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT / "scripts"))

# Bloom's verb/level data is canonically owned by blooms.py -- imported
# here rather than duplicated so Mode B classifies identically to the
# other importers (alignment_validator, preflight_checks, mlo_extractor,
# assurance_inventory, objective_extractor, audit_mlo_drift,
# audit_bloom_alignment, alignment_skill, assurance_audit).
from blooms import BLOOMS_LEVELS, BLOOMS_VERBS  # noqa: E402


def _maybe_open_in_browser(path: Path) -> dict[str, Any]:
    """Best-effort open the file on macOS via `open`. No-op on other OSes."""
    if platform.system() != "Darwin":
        return {"opened": False, "reason": "auto-open only on macOS"}
    try:
        subprocess.run(["open", str(path)], check=True, capture_output=True)
        return {"opened": True}
    except (FileNotFoundError, subprocess.CalledProcessError) as exc:
        return {"opened": False, "reason": str(exc)}


BLOOM_LEVELS = ("Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create")
BLOOM_ABBREVIATIONS = {
    "Remember": "R",
    "Understand": "U",
    "Apply": "A",
    "Analyze": "An",
    "Evaluate": "E",
    "Create": "C",
}
# Reverse of BLOOM_ABBREVIATIONS -- bridges Mode B's own abbreviation
# convention ("R".."C") to blooms.BLOOMS_LEVELS' full-name ordinal ranks.
_ABBREVIATION_TO_BLOOM_LEVEL = {abbreviation: level for level, abbreviation in BLOOM_ABBREVIATIONS.items()}
BLOOM_UNDETERMINED = "bloom_undetermined"
SIDEBAR_FLAGS = ("unaligned", "bloom_mismatch", "clo_only", "redundant")


def _bloom_rank(abbreviation: str) -> int:
    """Ordinal rank for a Mode B Bloom abbreviation, sourced from the
    canonical `blooms.BLOOMS_LEVELS` (single source of truth for level
    order) rather than a private duplicate."""
    return BLOOMS_LEVELS.get(_ABBREVIATION_TO_BLOOM_LEVEL.get(abbreviation, ""), 0)


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


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


def _flatten_refs(value: Any) -> list[str]:
    refs: list[str] = []
    for item in _as_list(value):
        if item in (None, ""):
            continue
        if isinstance(item, dict):
            for key in ("id", "label", "name", "text"):
                if item.get(key):
                    refs.append(str(item[key]))
        else:
            refs.append(str(item))
    return refs


def _course_config_path(course_id: str) -> Path:
    import canvas_api

    return Path(canvas_api.get_plugin_root()) / "courses" / str(course_id) / "course-config.json"


def _load_item_report_course_config(course_id: str) -> dict[str, Any]:
    path = _course_config_path(course_id)
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


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


def _objective_value_text(value: Any) -> str:
    if isinstance(value, dict):
        return str(value.get("text") or value.get("description") or value.get("title") or value.get("label") or value.get("id") or "")
    return str(value or "")


def _objective_value_id(value: Any, fallback: str) -> str:
    if isinstance(value, dict):
        return str(value.get("id") or value.get("label") or fallback)
    return fallback


def _objective_value_label(value: Any, fallback: str) -> str:
    if isinstance(value, dict):
        return str(value.get("label") or value.get("id") or fallback)
    return fallback


def _objective_required_bloom(value: Any) -> str:
    if not isinstance(value, dict):
        inferred, _ = _infer_bloom(_objective_value_text(value))
        return "" if inferred == BLOOM_UNDETERMINED else inferred
    raw = str(
        value.get("blooms")
        or value.get("bloom")
        or value.get("bloom_level")
        or value.get("blooms_level")
        or ""
    )
    for level, abbreviation in BLOOM_ABBREVIATIONS.items():
        if raw.lower() in {level.lower(), abbreviation.lower()}:
            return abbreviation
    inferred, _ = _infer_bloom(_objective_value_text(value))
    return "" if inferred == BLOOM_UNDETERMINED else inferred


def _make_objective(value: Any, prefix: str, index: int, *, module_number: Any = None) -> dict[str, Any]:
    display_label = f"{prefix}_{index}"
    fallback_id = f"{prefix}{index}"
    raw_id = _objective_value_id(value, fallback_id)
    raw_label = _objective_value_label(value, display_label)
    text = _objective_value_text(value)
    text_aliases = {raw_id, raw_label, display_label, display_label.replace("_", " ")}
    explicit_aliases = set(text_aliases)
    explicit_aliases.add(str(index))
    return {
        "id": raw_id,
        "label": display_label,
        "raw_label": raw_label,
        "text": text,
        "required_bloom": _objective_required_bloom(value),
        "module_number": module_number,
        "text_aliases": {alias for alias in text_aliases if alias},
        "explicit_aliases": {alias for alias in explicit_aliases if alias},
    }


def _collect_objectives(course_config: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    clos = []
    clo_values: list[Any] = []
    for field in ("clos", "clo", "course_learning_outcomes", "course_outcomes", "learning_outcomes"):
        clo_values.extend(_as_list(course_config.get(field)))
    for index, clo in enumerate(clo_values, start=1):
        clos.append(_make_objective(clo, "CLO", index))

    mlos = []
    mlo_values: list[tuple[Any, Any]] = []
    for field in ("mlos", "module_learning_objectives", "objectives"):
        for mlo in _as_list(course_config.get(field)):
            mlo_values.append((mlo, None))
    for module in _as_list(course_config.get("modules")):
        if not isinstance(module, dict):
            continue
        module_number = module.get("number")
        for field in ("objectives", "mlos", "module_learning_objectives", "mlos_draft"):
            for mlo in _as_list(module.get(field)):
                mlo_values.append((mlo, module_number))
    for index, (mlo, module_number) in enumerate(mlo_values, start=1):
        mlos.append(_make_objective(mlo, "MLO", index, module_number=module_number))
    return mlos, clos


def _ref_in_text(ref: str, text: str) -> bool:
    if not ref:
        return False
    compact_ref = _compact_ref(ref)
    compact_text = _compact_ref(text)
    if compact_ref and compact_ref in compact_text:
        return True
    return bool(re.search(rf"\b{re.escape(ref)}\b", text, flags=re.IGNORECASE))


def _verb_forms(verb: str) -> set[str]:
    forms = {verb, f"{verb}s"}
    if verb.endswith("e"):
        forms.add(f"{verb}d")
        forms.add(f"{verb[:-1]}ing")
    else:
        forms.add(f"{verb}ed")
        forms.add(f"{verb}ing")
    return forms


def _infer_bloom(text: str) -> tuple[str, str | None]:
    lowered = text.lower()
    matches: dict[str, str] = {}
    for level in BLOOM_LEVELS:
        for verb in BLOOMS_VERBS[level]:
            pattern = "|".join(re.escape(form) for form in sorted(_verb_forms(verb), key=len, reverse=True))
            if re.search(rf"\b(?:{pattern})\b", lowered):
                matches[level] = verb
                break
    if len(matches) == 1:
        level, verb = next(iter(matches.items()))
        return BLOOM_ABBREVIATIONS[level], verb
    return BLOOM_UNDETERMINED, None


def infer_bloom_abbreviation(text: str) -> str:
    return _infer_bloom(text)[0]


def _linked_refs(item: dict[str, Any], objectives: list[dict[str, Any]], explicit_keys: tuple[str, ...]) -> list[str]:
    explicit: list[str] = []
    for key in explicit_keys:
        explicit.extend(_flatten_refs(item.get(key)))
    compact_explicit = {_compact_ref(value) for value in explicit if value}
    text = str(item.get("text") or "")
    linked = []
    for objective in objectives:
        explicit_aliases = {_compact_ref(alias) for alias in objective.get("explicit_aliases", set())}
        if compact_explicit.intersection(explicit_aliases):
            linked.append(objective["id"])
            continue
        if any(_ref_in_text(alias, text) for alias in objective.get("text_aliases", set())):
            linked.append(objective["id"])
    return linked


def _collect_item_rows(config: dict[str, Any], object_id: str) -> tuple[str, str, list[dict[str, Any]]]:
    import canvas_api

    rows: list[dict[str, Any]] = []
    quiz = canvas_api.get_quiz(config, object_id)
    if quiz:
        for index, question in enumerate(canvas_api.get_quiz_questions(config, object_id) or [], start=1):
            text = _strip_tags(question.get("question_text") or question.get("text") or "")
            rows.append({
                "id": f"quiz:{object_id}:q{question.get('id') or index}",
                "label": str(question.get("question_name") or f"Quiz {object_id} Q{index}"),
                "type": "quiz_question",
                "text": text,
                "mlo_ids": question.get("mlo_ids"),
                "mlos": question.get("mlos"),
                "clo_ids": question.get("clo_ids"),
                "clos": question.get("clos"),
            })
        return "quiz", str(quiz.get("title") or f"Quiz {object_id}"), rows

    assignment = canvas_api.get_assignment(config, object_id)
    if assignment:
        rows.append({
            "id": f"assignment:{object_id}",
            "label": str(assignment.get("name") or f"Assignment {object_id}"),
            "type": "assignment",
            "text": _strip_tags(assignment.get("description") or assignment.get("name") or ""),
            "mlo_ids": assignment.get("mlo_ids"),
            "mlos": assignment.get("mlos"),
            "clo_ids": assignment.get("clo_ids"),
            "clos": assignment.get("clos"),
        })
        return "assignment", str(assignment.get("name") or f"Assignment {object_id}"), rows

    raise ValueError(f"Could not fetch quiz or assignment for object_id {object_id}")


def _required_bloom_level(mlo: dict[str, Any]) -> str:
    raw = str(mlo.get("required_bloom") or "")
    if raw in _ABBREVIATION_TO_BLOOM_LEVEL:
        return raw
    for level, abbreviation in BLOOM_ABBREVIATIONS.items():
        if raw.lower() in {level.lower(), abbreviation.lower()}:
            return abbreviation
    return ""


def _row_flags(row: dict[str, Any], mlos: list[dict[str, Any]], bloom_level: str) -> list[str]:
    flags = []
    if bloom_level == BLOOM_UNDETERMINED:
        flags.append(BLOOM_UNDETERMINED)
    if not row["linked_mlos"] and not row["linked_clos"]:
        flags.append("unaligned")
    if not row["linked_mlos"] and row["linked_clos"]:
        flags.append("clo_only")
    for mlo in mlos:
        if mlo["id"] not in row["linked_mlos"]:
            continue
        required = _required_bloom_level(mlo)
        if required and bloom_level != BLOOM_UNDETERMINED and _bloom_rank(bloom_level) < _bloom_rank(required):
            flags.append("bloom_mismatch")
    return flags


def build_item_alignment_model(course_config: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
    """Build a deterministic item/objective matrix model for report rendering."""
    mlos, clos = _collect_objectives(course_config)
    for row in rows:
        row["linked_mlos"] = _linked_refs(row, mlos, ("mlo_ids", "mlos", "mlo"))
        row["linked_clos"] = _linked_refs(row, clos, ("clo_ids", "clos", "clo"))
        bloom_level, bloom_verb = _infer_bloom(row["text"])
        row["bloom_level"] = bloom_level
        row["bloom_verb"] = bloom_verb
        row["flags"] = _row_flags(row, mlos, bloom_level)

    duplicate_counts = Counter(
        tuple(sorted(row["linked_mlos"]))
        for row in rows
        if row["linked_mlos"]
    )
    for row in rows:
        key = tuple(sorted(row["linked_mlos"]))
        if key and duplicate_counts[key] >= 3:
            row["flags"].append("redundant")
        row["flags"] = sorted(set(row["flags"]))

    flag_counts = {
        flag: sum(1 for row in rows if flag in row["flags"])
        for flag in SIDEBAR_FLAGS
    }
    return {"mlos": mlos, "clos": clos, "rows": rows, "flag_counts": flag_counts}


def _matrix_cell(row: dict[str, Any], objective: dict[str, Any]) -> str:
    linked = objective["id"] in row["linked_mlos"] or objective["id"] in row["linked_clos"]
    if not linked or row["bloom_level"] == BLOOM_UNDETERMINED:
        return ""
    return str(row["bloom_level"])


def _report_template() -> str:
    template_path = PLUGIN_ROOT / "templates" / "report-html" / "report-base.html"
    return template_path.read_text(encoding="utf-8")


def _render_item_alignment_html(
    *,
    course_id: str,
    object_id: str,
    target_type: str,
    target_title: str,
    model: dict[str, Any],
    generated_at: datetime,
    dry_run: bool,
) -> str:
    all_objectives = [*model["mlos"], *model["clos"]]
    header_cells = "".join(
        f'<th scope="col" data-objective-id="{html.escape(obj["id"])}">{html.escape(obj["label"])}</th>'
        for obj in all_objectives
    )
    body_rows = []
    for row in model["rows"]:
        flag_class = " ".join(html.escape(flag) for flag in row["flags"])
        cells = "".join(
            f'<td data-objective-id="{html.escape(obj["id"])}">{html.escape(_matrix_cell(row, obj))}</td>'
            for obj in all_objectives
        )
        body_rows.append(
            f'<tr data-item-id="{html.escape(row["id"])}" class="{flag_class}">'
            f'<th scope="row">{html.escape(row["label"])}</th>{cells}</tr>'
        )

    sidebar = "".join(
        f'<li class="flag {html.escape(flag)}"><strong>{html.escape(flag)}</strong>: {count}</li>'
        for flag, count in model["flag_counts"].items()
    )
    if not sidebar:
        sidebar = "<li>No item alignment flags.</li>"

    report_body = f"""
  <div class="report-section" data-section-id="item-alignment-matrix">
    <div class="report-section-header">
      <span class="report-section-title">Item Alignment Matrix</span>
      <span class="report-section-badge badge-warn">{sum(model["flag_counts"].values())} FLAGS</span>
    </div>
    <div class="report-section-body">
      <p style="font-size:13px;color:#555;margin-bottom:12px;">{html.escape(target_title)} ({html.escape(target_type)} {html.escape(object_id)})</p>
      <aside class="item-alignment-sidebar" aria-label="Sidebar flags" style="margin-bottom:16px;padding:12px;border:1px solid #e4e4e7;border-radius:6px;background:#fafafa;">
        <h2 style="font-size:14px;margin-bottom:8px;">Sidebar Flags</h2>
        <ul>{sidebar}</ul>
      </aside>
      <div style="overflow-x:auto;">
        <table class="item-alignment-matrix" style="width:100%;border-collapse:collapse;font-size:12px;">
          <thead><tr><th scope="col">Assessment Item</th>{header_cells}</tr></thead>
          <tbody>{''.join(body_rows)}</tbody>
        </table>
      </div>
    </div>
  </div>
"""
    base = _report_template()
    replacements = {
        "{{REPORT_TITLE}}": f"Alignment Matrix {object_id}",
        "{{COURSE_NAME}}": f"Course {course_id}",
        "{{REPORT_TYPE}}": "Item Alignment",
        "{{REPORT_DATE}}": generated_at.strftime("%Y-%m-%d %H:%M UTC"),
        "{{PAGES_SCANNED}}": str(len(model["rows"])),
        "{{PASS_COUNT}}": str(sum(1 for row in model["rows"] if not any(flag in SIDEBAR_FLAGS for flag in row["flags"]))),
        "{{WARN_COUNT}}": str(sum(model["flag_counts"].values())),
        "{{FAIL_COUNT}}": str(model["flag_counts"].get("unaligned", 0) + model["flag_counts"].get("bloom_mismatch", 0)),
        "{{REPORT_BODY}}": report_body,
    }
    rendered = base
    for old, new in replacements.items():
        rendered = rendered.replace(old, new)
    rendered = rendered.replace("</body>", f"<!-- dry_run={str(dry_run).lower()} -->\n</body>")
    return rendered


def build_item_alignment_report(
    course_id: str,
    object_id: str,
    *,
    dry_run: bool = False,
) -> Path:
    """Render a read-only item-vs-objective Bloom matrix."""
    import canvas_api

    config = canvas_api.get_config(course_id=course_id)
    canvas_api.require_course_id(config)
    course_config = _load_item_report_course_config(course_id)
    target_type, target_title, rows = _collect_item_rows(config, str(object_id))
    model = build_item_alignment_model(course_config, rows)

    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    reports_dir = Path(canvas_api.get_plugin_root()) / "courses" / str(course_id) / "reports"
    reports_dir.mkdir(parents=True, exist_ok=True)
    safe_object_id = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(object_id)).strip("-") or "object"
    output_path = reports_dir / f"{timestamp}-alignment-{safe_object_id}.html"
    html_doc = _render_item_alignment_html(
        course_id=str(course_id),
        object_id=str(object_id),
        target_type=target_type,
        target_title=target_title,
        model=model,
        generated_at=datetime.now(timezone.utc),
        dry_run=dry_run,
    )
    output_path.write_text(html_doc, encoding="utf-8")
    return output_path


def run_mode_b(
    course_id: str,
    *,
    with_decisions: bool = False,
    open_browser: bool = True,
) -> dict[str, Any]:
    """Run Mode B end-to-end. Returns a structured summary dict.

    Pipeline:
      1. Build graph via `alignment_graph_preview.build_graph_with_canvas_data`
         (Canvas-first; `course-config.json` is optional planning-map override).
      2. Render `alignment-preview.html` via `alignment_preview_html.render_alignment_preview`.
      3. Optionally generate `mockup_decisions.json` via `alignment_mockup_decisions.build_decisions`.
      4. Optionally open the preview HTML in the default browser (macOS only).
    """
    summary: dict[str, Any] = {
        "ok": False,
        "course_id": str(course_id),
        "steps": [],
    }

    # ---- Step 1: build graph ----
    print(f"[mode-b] Step 1/3 — building alignment graph for course {course_id}", file=sys.stderr)
    try:
        import alignment_graph_preview as agp
        from canvas_api import get_config

        config_path = agp.default_config_path(course_id)
        course_config: dict[str, Any] = {}
        if config_path.exists():
            try:
                course_config = agp.load_course_config(config_path)
            except (json.JSONDecodeError, OSError) as exc:
                print(
                    f"[mode-b] WARNING: course-config.json unreadable ({exc}); proceeding Canvas-only.",
                    file=sys.stderr,
                )

        # Layer chat-derived inferred mappings on top of the canonical config.
        # Canonical declared fields always win — inferences only fill gaps.
        inferred_path = agp.default_inferred_mappings_path(course_id)
        inferred_loaded = None
        if inferred_path.exists():
            try:
                inferred_loaded = agp.load_inferred_mappings(inferred_path)
                print(
                    f"[mode-b] Layered chat-inferred mappings from {inferred_path}",
                    file=sys.stderr,
                )
            except ValueError as exc:
                # Malformed sidecar — fail fast so the user fixes it rather
                # than silently shipping a partial graph.
                summary["steps"].append({
                    "step": "graph",
                    "ok": False,
                    "error": f"inferred_mappings.json invalid: {exc}",
                })
                summary["failed_at"] = "graph"
                return summary
        merged_config = agp.merge_inferred_into_course_config(course_config, inferred_loaded)

        canvas_config = get_config(course_id=course_id)
        graph = agp.build_graph_with_canvas_data(canvas_config, merged_config or None)
        # Layer chat-derived inferences as INFERRED edges on the built graph.
        # This is the path that wires sidecar mappings onto Canvas-extracted
        # MLOs/CLOs (which never reach the config-merge path because they
        # live only in graph nodes, not in course-config.json).
        inferred_edges_added = agp.apply_inferred_mappings_to_graph(graph, inferred_loaded)
        graph_path = agp.default_graph_path(course_id)
        agp.write_graph(graph, graph_path)
        summary["steps"].append({
            "step": "graph",
            "ok": True,
            "output": str(graph_path),
            "nodes": len(graph.get("nodes", [])),
            "edges": len(graph.get("edges", [])),
            "source_mode": graph.get("source_mode"),
            "inferred_mappings_layered": bool(inferred_loaded),
            "inferred_edges_added": inferred_edges_added,
            "gaps": {key: len(value) for key, value in graph.get("gaps", {}).items()},
        })
    except Exception as exc:
        summary["steps"].append({"step": "graph", "ok": False, "error": str(exc)})
        summary["failed_at"] = "graph"
        return summary

    # ---- Step 2: render HTML preview ----
    print(f"[mode-b] Step 2/3 — rendering alignment-preview.html", file=sys.stderr)
    try:
        import alignment_preview_html as aph

        preview_path = aph.default_output_path(course_id)
        aph.write_preview(graph, preview_path)
        summary["steps"].append({
            "step": "preview",
            "ok": True,
            "output": str(preview_path),
        })
    except Exception as exc:
        summary["steps"].append({"step": "preview", "ok": False, "error": str(exc)})
        summary["failed_at"] = "preview"
        return summary

    # ---- Step 3 (optional): generate mockup decisions ----
    if with_decisions:
        print(f"[mode-b] Step 3/3 — generating mockup decisions", file=sys.stderr)
        try:
            import alignment_mockup_decisions as amd

            decisions = amd.build_decisions(graph)
            decisions_path = amd.default_output_path(course_id)
            amd.write_decisions(decisions, decisions_path)
            summary["steps"].append({
                "step": "decisions",
                "ok": True,
                "output": str(decisions_path),
                "decisions_by_mlo": len(decisions.get("decisions_by_mlo", [])),
                "decisions_by_item": len(decisions.get("decisions_by_item", [])),
                "summary": decisions.get("summary", {}),
            })
        except Exception as exc:
            summary["steps"].append({"step": "decisions", "ok": False, "error": str(exc)})
            summary["failed_at"] = "decisions"
            return summary
    else:
        summary["steps"].append({"step": "decisions", "skipped": True})

    # ---- Auto-open preview ----
    if open_browser:
        summary["open"] = _maybe_open_in_browser(preview_path)

    summary["ok"] = True
    summary["preview_path"] = str(preview_path)
    return summary


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description=(
            "Run alignment Mode B end-to-end: build graph (Canvas-first) -> "
            "render HTML preview -> optionally generate mockup decisions -> "
            "auto-open preview. Single command for plain-English /alignment "
            "skill invocations."
        ),
    )
    parser.add_argument("--course-id", required=True, help="Canvas course ID.")
    parser.add_argument(
        "--with-decisions",
        action="store_true",
        help="Generate mockup_decisions.json (KEEP/ADD/REPLACE/OPTIONAL labels).",
    )
    parser.add_argument(
        "--no-open",
        action="store_true",
        help="Suppress the auto-open step (set on non-macOS or non-interactive runs).",
    )
    parser.add_argument(
        "--item-alignment-report",
        metavar="OBJECT_ID",
        help="Render an item alignment matrix for a target quiz or assignment id.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Write reports without making Canvas write calls.",
    )
    args = parser.parse_args(argv)

    if args.item_alignment_report:
        try:
            output_path = build_item_alignment_report(
                args.course_id,
                args.item_alignment_report,
                dry_run=args.dry_run,
            )
        except Exception as exc:
            print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
            return 1
        print(json.dumps({"ok": True, "output": str(output_path), "dry_run": bool(args.dry_run)}, indent=2))
        return 0

    summary = run_mode_b(
        args.course_id,
        with_decisions=args.with_decisions,
        open_browser=not args.no_open,
    )
    print(json.dumps(summary, indent=2))
    return 0 if summary.get("ok") else 1


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