#!/usr/bin/env python3
"""Unified Staging Review — generate a single scrollable review page for all staged content.

Renders every staged item (pages, assignments, discussions, quizzes) for a course
in one document with a sidebar TOC, preflight badges, module grouping, and
Canvas-accurate styling.

Usage:
    python3 scripts/unified_staging_review.py --course-id 255724
    python3 scripts/unified_staging_review.py --course-id 255724 --open
"""

import argparse
import html as html_mod
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path

# S4: stdlib-only sanitizer (no pip dependency).  Import lazily so the module
# still loads when html_sanitize.py is not yet on sys.path (e.g. test mocks).
try:
    from html_sanitize import sanitize_html as _sanitize_html
except ImportError:  # pragma: no cover
    def _sanitize_html(s: str) -> str:  # type: ignore[misc]
        return s

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
COURSES_DIR = PLUGIN_ROOT / "courses"
QUIZ_SHELL_CSS = (PLUGIN_ROOT / "templates" / "staging-html" / "quiz-shell.html")

EXTENSIONS = {
    "pages": ".html",
    "assignments": ".html",
    "discussions": ".html",
    "quizzes": ".json",
    "interactives": ".html",
}

# ── Shared utilities (Phase 1) ──────────────────────────────────────────

TYPE_COLORS = {
    "overview": ("#0374b5", "Overview"),
    "lesson": ("#2d8659", "Lesson"),
    "knowledge-check": ("#b5540a", "Knowledge Check"),
    "guided-practice": ("#7b2d8b", "Guided Practice"),
    "assignment": ("#c4602d", "Assignment"),
    "discussion": ("#1a6b8a", "Discussion"),
    "conclusion": ("#8C1D40", "Conclusion"),
    "resources": ("#4a6741", "Resources"),
    "prepare-to-learn": ("#5c6bc0", "Prepare to Learn"),
    "orientation": ("#455a64", "Orientation"),
}
SUBHEADER_COLOR = "#94a3b8"

# Staged-file token → local preview URL. Mirrors staging_server._rewrite_staged_file_tokens
# so banners/images embedded as asucb-staged-file://{key} actually render in the unified
# review iframe (the solo preview route rewrites them; this one used to skip it).
_STAGED_FILE_TOKEN_RE = re.compile(r'asucb-staged-file://([A-Za-z0-9._-]+)')
_FILE_KEY_RE = re.compile(r'[A-Za-z0-9._-]+')


def _rewrite_staged_file_tokens(content: str, course_id: str) -> str:
    if not course_id or "asucb-staged-file://" not in content:
        return content

    def repl(m):
        key = m.group(1)
        if not _FILE_KEY_RE.fullmatch(key) or key in {".", ".."}:
            return m.group(0)
        return f"/courses/{html_mod.escape(str(course_id), quote=True)}/staging-files/{html_mod.escape(key, quote=True)}"

    return _STAGED_FILE_TOKEN_RE.sub(repl, content)


def _sandbox_preview(raw_html: str, course_id: str = "") -> str:
    """S4: Wrap untrusted Canvas body HTML in a sandboxed iframe (primary barrier).

    1. Sanitize with the stdlib allowlist sanitizer (defense-in-depth layer).
    2. Embed in <iframe sandbox srcdoc="..."> with no allow-scripts and no
       allow-same-origin → null origin, scripts inert, no reach to the staging
       API from injected content.
    3. srcdoc value is HTML-attribute-escaped so a quote/angle sequence in the
       content cannot break out of the attribute.

    Note: cross-origin auto-resize is not available when sandbox disallows
    same-origin; the iframe uses a generous min-height and scrolls instead.
    The HTML Source editor textarea is unaffected — only the rendered preview
    is sandboxed; the source panel keeps the raw HTML for editing.
    """
    safe_body = _rewrite_staged_file_tokens(_sanitize_html(raw_html), course_id)
    escaped_srcdoc = html_mod.escape(safe_body, quote=True)
    return (
        f'<iframe sandbox srcdoc="{escaped_srcdoc}" loading="lazy" '
        f'style="width:100%;min-height:480px;border:0;display:block" '
        f'title="Sandboxed content preview"></iframe>'
    )


def classify_page_type(slug, page_type=None):
    """Return (color, label) based on slug or object type."""
    if page_type == "subheader":
        return SUBHEADER_COLOR, "SubHeader"
    s = slug.lower()
    compact = s.replace("-", "")
    if "subheader" in compact or s.endswith("-section"):
        return SUBHEADER_COLOR, "SubHeader"
    for key, (color, label) in TYPE_COLORS.items():
        if key.replace("-", "") in compact:
            return color, label
    return "#6a7883", "Page"


def extract_module_number(slug):
    """Extract module number from slug like 'm3-overview' → 3."""
    m = re.match(r'^m(?:odule-?)?(\d+)', slug)
    return int(m.group(1)) if m else 999


def slug_to_title(slug):
    """Convert slug to readable title: 'm1-overview' → 'Overview'."""
    cleaned = re.sub(r'^m\d+-', '', slug)
    cleaned = re.sub(r'^\d{1,3}[a-z]?-', '', cleaned)
    cleaned = re.sub(r'-\d+$', '', cleaned)
    return cleaned.replace('-', ' ').title()


def is_subheader_item(item):
    return item.get("type") == "pages" and item.get("page_type") == "subheader"


_HEADING_RE = re.compile(r"<h[1-6][^>]*>(.*?)</h[1-6]>", re.DOTALL | re.IGNORECASE)
_TAG_STRIP_RE = re.compile(r"<[^>]+>")


def _extract_first_heading_text(html_content):
    """Return the trimmed text of the first <h1>-<h6> in html_content, or ''.

    Cheap regex-based extraction (no full HTML parser). Strips nested tags and
    collapses whitespace. Returns "" when no heading is found or text is blank.
    """
    if not html_content or not isinstance(html_content, str):
        return ""
    m = _HEADING_RE.search(html_content)
    if not m:
        return ""
    inner = _TAG_STRIP_RE.sub("", m.group(1))
    inner = html_mod.unescape(inner)
    inner = re.sub(r"\s+", " ", inner).strip()
    return inner


# ── Canvas module mapping (Fix: sidepanel reflects Canvas module structure) ──

MODULE_CACHE_TTL_SECONDS = 3600  # 1 hour
_MOCKUP_MODULE_RE = re.compile(r"mockup", re.IGNORECASE)
_CANVAS_MODULE_N_RE = re.compile(r"\bmodule\s*0*(\d+)\b", re.IGNORECASE)


def _module_cache_path(course_id):
    return COURSES_DIR / str(course_id) / ".module_cache.json"


def fetch_canvas_modules(course_id, refresh=False):
    """Resolve the Canvas module list for a course with caching + graceful degradation.

    Returns a list of Canvas module dicts (each with at least 'id', 'name',
    'position', and 'items' when available), or None when Canvas is unreachable
    or returns no data. A None return signals the caller to fall back to the
    local module_number heuristic — offline review must never break.

    Caching: the fetched list is written to courses/{id}/.module_cache.json with
    a timestamp and reused while younger than MODULE_CACHE_TTL_SECONDS. Pass
    refresh=True to bypass the cache and re-fetch.
    """
    cache_path = _module_cache_path(course_id)

    # Serve from cache when fresh and not force-refreshing.
    if not refresh and cache_path.exists():
        try:
            cached = json.loads(cache_path.read_text(encoding="utf-8"))
            ts = cached.get("fetched_at", 0)
            age = datetime.now().timestamp() - ts
            modules = cached.get("modules")
            if isinstance(modules, list) and age < MODULE_CACHE_TTL_SECONDS:
                return modules
        except (json.JSONDecodeError, OSError, TypeError, ValueError):
            pass  # fall through to fetch

    # Fetch from Canvas. Wrap everything — any failure degrades to local grouping.
    try:
        # Import canvas_api FIRST: its module-level load_dotenv() populates
        # os.environ from .env. The token lives in .env, not the ambient
        # environment, so checking os.environ before this import always
        # short-circuits to offline mode on a fresh process (the bug fixed
        # here). After the import, the token check reflects real credentials.
        import canvas_api  # noqa: F401 — import triggers load_dotenv()
        if not os.environ.get("CANVAS_TOKEN"):
            return None  # genuinely no credentials → offline mode, never sys.exit
        config = canvas_api.get_config(course_id=course_id)
        if not config.get("course_id"):
            return None
        modules = canvas_api.get_modules(config, include_items=True)
        if not isinstance(modules, list):
            return None
        try:
            cache_path.parent.mkdir(parents=True, exist_ok=True)
            cache_path.write_text(
                json.dumps(
                    {"fetched_at": datetime.now().timestamp(), "modules": modules},
                    ensure_ascii=False,
                ),
                encoding="utf-8",
            )
        except OSError:
            pass  # caching is best-effort; still return the live data
        return modules
    except SystemExit:
        # get_config calls sys.exit when CANVAS_TOKEN missing; treat as offline.
        return None
    except Exception:  # noqa: BLE001 — any network/API failure degrades gracefully
        return None


def _canvas_module_name_n(module):
    """Return the module number embedded in a Canvas module name, or None.

    Matches 'Module 3', 'Module 03', 'Module 3: Categorical Predictors', etc.
    """
    name = str(module.get("name", ""))
    m = _CANVAS_MODULE_N_RE.search(name)
    return int(m.group(1)) if m else None


def _module_contains_canvas_id(module, canvas_id):
    """Return True if a Canvas module's items contain the given content_id.

    Matches against module item content_id (assignments/quizzes/discussions/files)
    and page_url (pages). Comparison is string-normalized.
    """
    if canvas_id is None:
        return False
    target = str(canvas_id)
    for it in module.get("items", []) or []:
        if str(it.get("content_id", "")) == target:
            return True
        if str(it.get("page_url", "")) == target:
            return True
        if str(it.get("id", "")) == target:
            return True
    return False


def map_item_to_canvas_module(item, canvas_modules):
    """Map a single staged item to its real Canvas module. First match wins.

    Mapping rules (per spec):
      1. meta.target_module_id present → that Canvas module.
      2. meta.kind == "mockup" → the "Mockups and Suggestions" module
         (name contains "mockup", case-insensitive).
      3. meta.source_canvas_id present → the module whose items contain it.
      4. Fallback → module_number → Canvas module named "Module {N}".
      5. Unresolved → None (caller routes to the Unmapped bucket).

    Returns the matched Canvas module dict, or None when unresolved.
    """
    if not canvas_modules:
        return None
    meta = item.get("meta") or {}

    # Rule 1: explicit target_module_id
    target_id = meta.get("target_module_id")
    if target_id is not None:
        for mod in canvas_modules:
            if str(mod.get("id", "")) == str(target_id):
                return mod

    # Rule 2: mockup → "Mockups and Suggestions" module
    if str(meta.get("kind", "")).lower() == "mockup":
        for mod in canvas_modules:
            if _MOCKUP_MODULE_RE.search(str(mod.get("name", ""))):
                return mod

    # Rule 3: source_canvas_id → module containing that content_id
    source_id = meta.get("source_canvas_id")
    if source_id is not None:
        for mod in canvas_modules:
            if _module_contains_canvas_id(mod, source_id):
                return mod

    # Rule 4: module_number → "Module {N}" by name
    mod_num = item.get("module_number")
    if isinstance(mod_num, int) and 0 <= mod_num < 999:
        for mod in canvas_modules:
            if _canvas_module_name_n(mod) == mod_num:
                return mod

    # Rule 5: unresolved
    return None


def scan_staged_items(course_id, canvas_modules=None):
    """Scan all staged items for a course across all types.

    Returns list of dicts: {type, id, slug, path, size, modified, content, preflight}

    When canvas_modules is provided (a list of Canvas module dicts), each item is
    mapped to its real Canvas module via map_item_to_canvas_module and the result
    is attached as item["canvas_module"] (the matched module dict or None). Items
    that resolve keep their generic module_number too, so the offline heuristic
    remains the floor when no Canvas module matches.
    """
    staging_dir = COURSES_DIR / str(course_id) / "staging"
    items = []

    for obj_type, ext in EXTENSIONS.items():
        type_dir = staging_dir / obj_type
        if not type_dir.is_dir():
            continue
        candidates = type_dir.rglob(f"*{ext}") if obj_type == "interactives" else type_dir.iterdir()
        for f in sorted(candidates):
            if not f.is_file():
                continue
            if f.name.startswith(".") or f.name.startswith("_"):
                continue
            if not f.name.endswith(ext):
                continue
            # Skip sidecar files
            if ".issues.json" in f.name or ".vision.json" in f.name or ".meta.json" in f.name:
                continue

            if obj_type == "interactives":
                obj_id = str(f.relative_to(type_dir))[: -len(ext)]
            else:
                obj_id = f.name[: -len(ext)]
            content = f.read_text(encoding="utf-8")

            # Load preflight sidecar if exists
            preflight = None
            for sidecar_name in [f.name + ".issues.json", obj_id + ".issues.json"]:
                sidecar_path = type_dir / sidecar_name
                if sidecar_path.exists():
                    try:
                        preflight = json.loads(sidecar_path.read_text(encoding="utf-8"))
                    except (json.JSONDecodeError, OSError):
                        pass
                    break

            # Load .meta.json sidecar for module grouping and title
            meta = None
            for meta_name in [f.name + ".meta.json", obj_id + ".meta.json"]:
                meta_path = type_dir / meta_name
                if meta_path.exists():
                    try:
                        meta = json.loads(meta_path.read_text(encoding="utf-8"))
                    except (json.JSONDecodeError, OSError):
                        pass
                    break

            # For quizzes, fall back to title embedded in the JSON itself
            if obj_type == "quizzes" and (not meta or not meta.get("title")):
                try:
                    qd = json.loads(content)
                    qtitle = (qd.get("quiz", {}).get("title")
                              or qd.get("quiz_title"))
                    if qtitle:
                        meta = dict(meta or {}, title=qtitle)
                except (json.JSONDecodeError, TypeError):
                    pass

            # For HTML-bodied content (pages/assignments/discussions), fall back
            # to first heading text when meta.title is missing. Explicit meta wins.
            if (obj_type in ("pages", "assignments", "discussions")
                    and (not meta or not meta.get("title"))):
                heading_title = _extract_first_heading_text(content)
                if heading_title:
                    meta = dict(meta or {}, title=heading_title)

            # Determine module number. Cascade:
            #   1. Explicit meta.module_number wins
            #   2. Otherwise, auto-extract from obj_id filename for any type
            #   3. Otherwise 999 (Unmapped)
            if meta and "module_number" in meta:
                mod_num = meta["module_number"]
            elif obj_type == "interactives":
                mod_num = extract_module_number(obj_id.split("/", 1)[0])
            else:
                mod_num = extract_module_number(obj_id)

            # Use meta title if available
            meta_title = meta.get("title") if meta else None
            page_type = meta.get("page_type") if meta else None
            subheader_text = meta.get("subheader_text") if meta else None

            item = {
                "type": obj_type,
                "id": obj_id,
                "slug": obj_id if obj_type == "pages" else f"{obj_type}/{obj_id}",
                "path": str(f),
                "size": f.stat().st_size,
                "modified": datetime.fromtimestamp(f.stat().st_mtime),
                "content": content,
                "preflight": preflight,
                "module_number": mod_num,
                "meta_title": meta_title,
                "page_type": page_type,
                "subheader_text": subheader_text,
                "meta": meta or {},
            }
            # Map to real Canvas module when a module list was supplied.
            item["canvas_module"] = map_item_to_canvas_module(item, canvas_modules)
            items.append(item)

    # Detect staged syllabus
    syllabus_file = staging_dir / "syllabus" / "syllabus_body.html"
    if syllabus_file.exists():
        items.append({
            "type": "syllabus",
            "id": "body",
            "slug": "syllabus/body",
            "path": str(syllabus_file),
            "size": syllabus_file.stat().st_size,
            "modified": datetime.fromtimestamp(syllabus_file.stat().st_mtime),
            "content": syllabus_file.read_text(encoding="utf-8"),
            "preflight": None,
            "module_number": -1,
            "meta_title": "Course Syllabus",
            "module_label": "Course Syllabus",
            "type_label": "Syllabus",
            "color": "#5b21b6",
            "meta": {},
            "canvas_module": None,
        })

    items.sort(key=lambda x: (_item_sort_key(x), x["type"], x["id"]))
    return items


def _item_sort_key(item):
    """Primary sort key for an item's module grouping.

    Syllabus first (-1). Items mapped to a real Canvas module sort by that
    module's position. Unmapped items fall back to their module_number so the
    offline heuristic order is preserved; truly unmapped items (999) sort last.
    """
    mod_num = item.get("module_number", 999)
    if mod_num == -1:
        return -1.0
    cmod = item.get("canvas_module")
    if cmod is not None:
        try:
            return float(cmod.get("position", 0))
        except (TypeError, ValueError):
            return 0.0
    return float(mod_num)


def _item_group(item):
    """Return (group_key, label, badge) for an item's module grouping.

    group_key is a hashable identity used to bucket items together.
      - Syllabus → ("syllabus",)
      - Mapped to Canvas module → ("canvas", module_id)
      - Unmapped with module_number → ("modnum", N)
      - Truly unmapped → ("unmapped",)

    label is the display string (real Canvas module name when resolved).
    badge is the short sidebar badge.
    """
    mod_num = item.get("module_number", 999)
    if mod_num == -1:
        return ("syllabus",), "Course Syllabus", "SYL"

    cmod = item.get("canvas_module")
    if cmod is not None:
        name = str(cmod.get("name", "")).strip() or f"Module {mod_num}"
        n = _canvas_module_name_n(cmod)
        if n is not None:
            badge = f"M{n}"
        else:
            pos = cmod.get("position")
            badge = f"M{pos}" if pos is not None else "M"
        return ("canvas", cmod.get("id")), name, badge

    if isinstance(mod_num, int) and mod_num < 999:
        return ("modnum", mod_num), f"Module {mod_num}", f"M{mod_num}"

    return ("unmapped",), "Unmapped Staged Content", "?"


def load_course_name(course_id):
    """Load course name from course-config.json, then registry.json fallback."""
    config_path = COURSES_DIR / str(course_id) / "course-config.json"
    if config_path.exists():
        try:
            cfg = json.loads(config_path.read_text(encoding="utf-8"))
            name = cfg.get("course", {}).get("title", "")
            if name:
                return name
        except (json.JSONDecodeError, OSError):
            pass
    registry_path = COURSES_DIR / "registry.json"
    if registry_path.exists():
        try:
            reg = json.loads(registry_path.read_text(encoding="utf-8"))
            for c in reg.get("courses", []):
                if str(c.get("id", "")) == str(course_id):
                    return c.get("name") or f"Course {course_id}"
        except (json.JSONDecodeError, OSError):
            pass
    return f"Course {course_id}"


def render_quiz_details_card(quiz_data, edit_url=""):
    """Render a one-row pill-chip summary of the quiz Details settings.

    ASU brand palette (per PR Spec 1):
      - slate    #4E4E4E  → quiz type
      - gold     #FFC627  → points
      - maroon   #8C1D40  → attempts
      - red      #B91C1C  → unpublished badge (only shown when published is false)

    Each chip is an anchor that jumps to the per-quiz edit page Details tab
    (`#details`). The chips are read-only summary; clicking opens the editor.
    """
    quiz = quiz_data.get("quiz", {}) if isinstance(quiz_data, dict) else {}
    quiz_type = quiz.get("quiz_type", "practice_quiz")
    points = quiz.get("points_possible", 0)
    attempts = quiz.get("allowed_attempts", -1)
    time_limit = quiz.get("time_limit")
    scoring = quiz.get("scoring_policy", "keep_highest")
    shuffle = quiz.get("shuffle_answers", True)
    published = quiz.get("published", False)

    type_label = "Practice Quiz" if quiz_type == "practice_quiz" else "Graded Quiz"
    attempts_label = "Unlimited attempts" if attempts == -1 else f"{attempts} attempts"
    time_label = "No time limit" if not time_limit else f"{time_limit} min"
    scoring_labels = {
        "keep_highest": "Keep highest",
        "keep_latest": "Keep latest",
        "take_average": "Average",
        "keep_average": "Average",
    }
    scoring_label = scoring_labels.get(scoring, scoring.replace("_", " ").title())
    shuffle_label = f"Shuffle: {'on' if shuffle else 'off'}"

    base = (
        "display:inline-block;padding:3px 10px;border-radius:12px;"
        "font-size:12px;font-weight:500;margin-right:6px;text-decoration:none;"
        "line-height:1.6;vertical-align:middle;"
    )
    chips = []

    def _chip(label, style, hash_target="#details"):
        href = f"{edit_url}{hash_target}" if edit_url else hash_target
        return (
            f'<a class="quiz-detail-chip" href="{html_mod.escape(href)}" '
            f'target="_blank" rel="noopener" style="{base}{style}">'
            f'{html_mod.escape(label)}</a>'
        )

    # Quiz type — slate filled
    chips.append(_chip(type_label, "background:#4E4E4E;color:#fff;"))
    # Points — gold filled, black text
    chips.append(_chip(f"{points} points", "background:#FFC627;color:#000;"))
    # Attempts — maroon filled
    chips.append(_chip(attempts_label, "background:#8C1D40;color:#fff;"))
    # Time limit — maroon outline
    chips.append(_chip(time_label, "background:transparent;color:#8C1D40;border:1px solid #8C1D40;"))
    # Scoring — slate outline
    chips.append(_chip(scoring_label, "background:transparent;color:#4E4E4E;border:1px solid #4E4E4E;"))
    # Shuffle — slate outline
    chips.append(_chip(shuffle_label, "background:transparent;color:#4E4E4E;border:1px solid #4E4E4E;"))
    # Unpublished — red outline, only when published is false
    if not published:
        chips.append(_chip("Unpublished", "background:transparent;color:#B91C1C;border:1px solid #B91C1C;"))

    return (
        '<div class="quiz-details-card" '
        'style="padding:10px 14px;background:#fafafa;border-bottom:1px solid var(--canvas-border);">'
        + "".join(chips)
        + "</div>"
    )


def render_quiz_cards(quiz_data, edit_url=""):
    """Render quiz JSON as HTML question cards (inline, no shell wrapper)."""
    quiz = quiz_data.get("quiz", {})
    questions = quiz_data.get("questions", [])

    quiz_title = quiz.get("title") or quiz_data.get("quiz_title") or "Quiz"
    quiz_type = quiz.get("quiz_type", "practice_quiz")
    points = quiz.get("points_possible", 0)
    attempts = quiz.get("allowed_attempts", -1)
    attempts_str = "Unlimited" if attempts == -1 else str(attempts)
    scoring = quiz.get("scoring_policy", "keep_highest")
    type_label = "Practice Quiz" if quiz_type == "practice_quiz" else "Graded Quiz"

    parts = [
        render_quiz_details_card(quiz_data, edit_url=edit_url),
        f"""
    <div class="quiz-inline-header">
      <strong>{html_mod.escape(quiz_title)}</strong>
      <span class="quiz-inline-meta">{type_label} &middot; {len(questions)} questions &middot; {points} pts &middot; {attempts_str} attempts &middot; {scoring.replace('_',' ')}</span>
    </div>"""
    ]

    type_names = {
        "multiple_choice_question": "MC",
        "multiple_answers_question": "Select All",
        "true_false_question": "T/F",
        "matching_question": "Matching",
        "ordering_question": "Ordering",
        "fill_in_multiple_blanks_question": "Fill-in",
        "numerical_question": "Numeric",
    }

    for i, q in enumerate(questions):
        q_name = q.get("question_name", f"Question {i + 1}")
        q_text = q.get("question_text", "")
        q_type = q.get("question_type", "multiple_choice_question")
        q_points = q.get("points_possible", 0)
        answers = q.get("answers", [])
        correct_fb = q.get("correct_comments_html") or q.get("correct_comments", "")
        incorrect_fb = q.get("incorrect_comments_html") or q.get("incorrect_comments", "")
        neutral_fb = q.get("neutral_comments_html") or q.get("neutral_comments", "")
        matching = q.get("matching", [])

        blooms = ""
        for level in ["Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create"]:
            if level.lower() in q_name.lower():
                blooms = level
                break

        tl = type_names.get(q_type, q_type)
        tags = f'<span class="tag tag-type">{html_mod.escape(tl)}</span>'
        if blooms:
            tags += f'<span class="tag tag-blooms">{html_mod.escape(blooms)}</span>'
        tags += f'<span class="tag tag-points">{q_points} pts</span>'

        if q_type == "matching_question":
            ans_html = '<table class="matching-table"><tr><th>Item</th><th>Match</th></tr>'
            for j, a in enumerate(answers):
                left = a.get("answer_match_left") or a.get("answer_text", "")
                right = a.get("answer_match_right")
                if right is None:
                    right = matching[j]["match_text"] if j < len(matching) else "—"
                ans_html += f'<tr><td>{html_mod.escape(left)}</td><td>{html_mod.escape(right)}</td></tr>'
            ans_html += "</table>"
        else:
            ans_html = '<ul class="answer-list">'
            for a in answers:
                w = a.get("answer_weight", 0)
                ic = w == 100
                cls = "correct" if ic else "incorrect"
                marker = "&#10003;" if ic else chr(ord("A") + answers.index(a))
                comment_raw = a.get("answer_comment")
                comment_html_field = a.get("answer_comments_html")
                if comment_html_field:
                    ch = f'<div class="answer-comment">{comment_html_field}</div>'
                elif comment_raw:
                    ch = f'<div class="answer-comment">{html_mod.escape(comment_raw)}</div>'
                else:
                    ch = ""
                ans_html += f'<li class="answer-item {cls}"><div class="answer-marker {"correct" if ic else "incorrect"}">{marker}</div><div class="answer-content">{html_mod.escape(a.get("answer_text",""))}{ch}</div></li>'
            ans_html += "</ul>"

        fb_html = ""
        if correct_fb:
            fb_html += f'<div class="feedback-section feedback-correct"><div class="feedback-label">Correct Answer Feedback</div>{correct_fb}</div>'
        if incorrect_fb:
            fb_html += f'<div class="feedback-section feedback-incorrect"><div class="feedback-label">Incorrect Answer Feedback</div>{incorrect_fb}</div>'
        if neutral_fb:
            fb_html += f'<div class="feedback-section feedback-neutral"><div class="feedback-label">General Feedback</div>{neutral_fb}</div>'

        parts.append(f"""
        <div class="question-card">
          <div class="question-header"><h3>Q{i+1}. {html_mod.escape(q_name)}</h3><div class="question-tags">{tags}</div></div>
          <div class="question-body"><div class="question-text">{q_text}</div>{ans_html}{fb_html}</div>
        </div>""")

    return "\n".join(parts)


def build_preflight_badge(preflight):
    """Build a small badge for the TOC from preflight data."""
    if not preflight:
        return ""
    total = preflight.get("total", 0)
    errors = preflight.get("errors", 0)
    warnings = preflight.get("warnings", 0)
    if total == 0:
        return '<span class="pf-badge pf-pass" title="All checks passed">&#10003;</span>'
    if errors > 0:
        return f'<span class="pf-badge pf-error" title="{errors} error(s)">{errors}</span>'
    if warnings > 0:
        return f'<span class="pf-badge pf-warn" title="{warnings} warning(s)">{warnings}</span>'
    return f'<span class="pf-badge pf-info" title="{total} info">i</span>'


def build_preflight_banner(item):
    """Build an expandable preflight issue banner for a section."""
    pf = item.get("preflight")
    if not pf or pf.get("total", 0) == 0:
        return ""
    errors = pf.get("errors", 0)
    warnings = pf.get("warnings", 0)
    info_count = pf.get("info", 0)

    parts = []
    if errors: parts.append(f'<span style="color:#d32f2f">{errors} error(s)</span>')
    if warnings: parts.append(f'<span style="color:#f57c00">{warnings} warning(s)</span>')
    if info_count: parts.append(f'<span style="color:#1976d2">{info_count} info</span>')
    summary = " &middot; ".join(parts)

    issue_rows = []
    for issue in pf.get("issues", []):
        sev = issue.get("severity", "info")
        icon = {"error": "&#128308;", "warning": "&#9888;&#65039;", "info": "&#8505;&#65039;"}.get(sev, "?")
        msg = html_mod.escape(issue.get("message", ""))
        loc = html_mod.escape(issue.get("location", ""))
        hint = issue.get("fix_hint", "")
        hint_html = f'<div class="pf-hint">{html_mod.escape(hint)}</div>' if hint else ""
        issue_rows.append(f'<div class="pf-issue pf-{sev}"><span class="pf-icon">{icon}</span><div class="pf-detail"><div class="pf-msg">{msg}</div><div class="pf-loc">{loc}</div>{hint_html}</div></div>')

    border = "#d32f2f" if errors else "#f57c00" if warnings else "#1976d2"
    return f'''<div class="pf-banner" style="border-left:4px solid {border}">
      <div class="pf-banner-header" onclick="this.parentElement.classList.toggle('pf-expanded')">
        <div><strong>Preflight:</strong> {summary}</div><span class="pf-toggle">&#9654; details</span>
      </div>
      <div class="pf-issues">{"".join(issue_rows)}</div>
    </div>'''


# ── Review page generation (Phase 2) ───────────────────────────────────

def scan_all_courses():
    """Scan all courses with staged content. Returns list of {id, name, count}."""
    courses = []
    if not COURSES_DIR.exists():
        return courses
    for course_dir in sorted(COURSES_DIR.iterdir()):
        if not course_dir.is_dir():
            continue
        staging_dir = course_dir / "staging"
        if not staging_dir.exists():
            continue
        items = scan_staged_items(course_dir.name)
        if items:
            courses.append({
                "id": course_dir.name,
                "name": load_course_name(course_dir.name),
                "count": len(items),
            })
    return courses


def generate_review(course_id, canvas_modules=None, refresh=False):
    """Generate the unified staging review HTML for a course.

    Args:
        course_id: Canvas course ID.
        canvas_modules: Optional pre-resolved list of Canvas module dicts. When
            None (default), the module list is fetched from Canvas (cached, with
            graceful degradation to local module_number grouping on any failure).
            Tests inject a fixture here to avoid hitting live Canvas.
        refresh: When True, bypass the module cache and re-fetch from Canvas.

    Returns HTML string.
    """
    if canvas_modules is None:
        canvas_modules = fetch_canvas_modules(course_id, refresh=refresh)
    items = scan_staged_items(course_id, canvas_modules=canvas_modules)
    course_name = load_course_name(course_id)
    timestamp = datetime.now().strftime("%B %d, %Y at %I:%M %p")
    total = len([item for item in items if not is_subheader_item(item)])
    other_courses = [c for c in scan_all_courses() if c["id"] != str(course_id)]
    serving_root = str(COURSES_DIR)

    if not items:
        safe_course = html_mod.escape(str(course_id))
        safe_root = html_mod.escape(serving_root)
        return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>No Staged Content</title>
  <style>
    body {{ font-family: system-ui, -apple-system, sans-serif; margin: 32px; color: #1f2937; }}
    h1 {{ font-size: 32px; margin-bottom: 16px; }}
    code {{ background: #f3f4f6; border: 1px solid #d1d5db; border-radius: 4px; padding: 2px 5px; }}
    .root {{ margin-top: 18px; padding: 12px 14px; background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 6px; }}
  </style>
</head>
<body data-serving-root="{safe_root}">
  <h1>No staged content for course {safe_course}</h1>
  <div class="root"><strong>Staging server root:</strong> <code>{safe_root}</code></div>
</body>
</html>"""

    # Group by module. Items are already sorted by _item_sort_key, so iterating
    # in order and bucketing by group key preserves Canvas position order (when
    # modules resolved) or the local module_number order (offline fallback).
    # The "unmapped" bucket always renders last.
    modules = {}
    group_meta = {}
    for item in items:
        gkey, glabel, gbadge = _item_group(item)
        if gkey not in modules:
            modules[gkey] = []
            group_meta[gkey] = (glabel, gbadge)
        modules[gkey].append(item)

    def _group_order(gkey):
        # Force the unmapped bucket last; everything else keeps insertion order.
        return (1 if gkey == ("unmapped",) else 0,)

    ordered_keys = sorted(modules.keys(), key=_group_order)

    # Build sidebar TOC
    toc_entries = []
    for gkey in ordered_keys:
        mod_items = modules[gkey]
        mod_label, mod_badge = group_meta[gkey]

        page_entries = []
        for item in mod_items:
            if item["type"] == "syllabus":
                color, type_label = "#5b21b6", "Syllabus"
                title = "Course Syllabus"
            elif item["type"] == "pages":
                color, type_label = classify_page_type(
                    item["id"], item.get("page_type"))
                title = item.get("subheader_text") or slug_to_title(item["id"])
            else:
                color = {"assignments": "#c4602d", "discussions": "#1a6b8a", "quizzes": "#b5540a", "interactives": "#8C1D40"}.get(item["type"], "#6a7883")
                type_label = {"assignments": "Assignment", "discussions": "Discussion", "quizzes": "Quiz", "interactives": "Interactive"}.get(item["type"], item["type"].title().rstrip("s"))
                title = item.get("meta_title") or f'{type_label} {item["id"]}'  
            badge = build_preflight_badge(item.get("preflight"))
            section_id = f'{item["type"]}-{item["id"]}'.replace("/", "-")
            if is_subheader_item(item):
                page_entries.append(
                    f'<div class="toc-subheader" title="{item["slug"]}">'
                    f'<span>{html_mod.escape(title)}</span></div>'
                )
                continue
            href = f'/courses/{course_id}/interactives/{item["id"]}' if item["type"] == "interactives" else f'#{section_id}'
            target = ' target="_blank" rel="noopener"' if item["type"] == "interactives" else ""
            page_entries.append(
                f'<a class="toc-page" href="{href}" title="{item["slug"]}"{target}>'
                f'<span class="toc-type-dot" style="background:{color}"></span>'
                f'{html_mod.escape(title)}{badge}</a>'
            )

        toc_entries.append(f'''
        <div class="toc-module">
          <div class="toc-module-header" onclick="this.parentElement.classList.toggle('collapsed')">
            <span class="toc-arrow">&#9662;</span>
            <span class="toc-mod-label">{mod_badge}</span>
            {html_mod.escape(mod_label)}
          </div>
          <div class="toc-pages">{"".join(page_entries)}</div>
        </div>''')

    # Build content sections
    sections = []
    item_num = 0
    for item in items:
        section_id = f'{item["type"]}-{item["id"]}'.replace("/", "-")
        # Use the real Canvas module name (when resolved) for the breadcrumb,
        # falling back to the local "Module N" / "Unmapped" heuristic.
        _gkey, mod_label, _gbadge = _item_group(item)
        if mod_label == "Unmapped Staged Content":
            mod_label = "Unmapped"

        if is_subheader_item(item):
            title = item.get("subheader_text") or slug_to_title(item["id"])
            sections.append(
                f'<div id="{section_id}" class="staging-subheader-divider">'
                f'<span>{html_mod.escape(title)}</span></div>'
            )
            continue

        item_num += 1

        if item["type"] == "syllabus":
            color = "#5b21b6"
            type_label = "Syllabus"
            title = "Course Syllabus"
            # S4: render untrusted body inside a sandboxed iframe (primary barrier).
            # The HTML Source editor textarea below still holds the raw source.
            content_html = _sandbox_preview(item["content"], course_id)
            read_only = False
        elif item["type"] == "pages":
            color, type_label = classify_page_type(
                item["id"], item.get("page_type"))
            title = slug_to_title(item["id"])
            # S4: sandbox untrusted page body.
            content_html = _sandbox_preview(item["content"], course_id)
            read_only = False
        elif item["type"] in ("assignments", "discussions"):
            color = "#c4602d" if item["type"] == "assignments" else "#1a6b8a"
            type_label = "Assignment" if item["type"] == "assignments" else "Discussion"
            title = item.get("meta_title") or f'{type_label} {item["id"]}'
            # S4: sandbox untrusted assignment/discussion body.
            content_html = _sandbox_preview(item["content"], course_id)
            read_only = False
        elif item["type"] == "quizzes":
            color = "#b5540a"
            type_label = "Quiz"
            title = item.get("meta_title") or f'Quiz {item["id"]}'
            quiz_edit_url = f'/courses/{course_id}/quizzes/{item["id"]}'
            try:
                quiz_data = json.loads(item["content"])
                content_html = render_quiz_cards(quiz_data, edit_url=quiz_edit_url)
                title = quiz_data.get("quiz", {}).get("title") or quiz_data.get("quiz_title") or title
            except json.JSONDecodeError:
                content_html = "<p>Invalid quiz JSON</p>"
            read_only = True
            content_html = (
                f'<div style="margin:0 0 12px 0;text-align:right">'
                f'<a class="quiz-edit-link" href="{quiz_edit_url}" target="_blank" rel="noopener" '
                f'style="display:inline-block;background:#8C1D40;color:#fff;padding:6px 14px;border-radius:4px;'
                f'font-size:13px;font-weight:500;text-decoration:none">'
                f'Open in editor &rarr;</a></div>'
                + content_html
            )
        else:  # interactives
            color = "#8C1D40"
            type_label = "Interactive"
            title = item.get("meta_title") or f'Interactive {slug_to_title(Path(item["id"]).stem)}'
            preview_url = f'/courses/{course_id}/interactives/{item["id"]}'
            content_html = (
                f'<p>This interactive is reviewed in its iframe preview shell.</p>'
                f'<p><a class="quiz-edit-link" href="{preview_url}" target="_blank" rel="noopener" '
                f'style="display:inline-block;background:#8C1D40;color:#fff;padding:8px 16px;border-radius:4px;'
                f'font-size:13px;font-weight:500;text-decoration:none">'
                f'Open interactive preview &rarr;</a></p>'
            )
            read_only = True

        ro_badge = ' <span class="ro-badge">Read-only</span>' if read_only else ''
        size_kb = item["size"] / 1024
        modified = item["modified"].strftime("%b %d, %I:%M %p")
        pf_banner = build_preflight_banner(item)

        # Solo preview — open this item alone in its full Canvas-shell preview.
        solo_btn = ""
        if item["type"] in ("pages", "assignments", "discussions", "quizzes"):
            solo_url = f'/courses/{html_mod.escape(str(course_id), quote=True)}/preview/{item["type"]}/{html_mod.escape(str(item["id"]), quote=True)}'
            solo_btn = (
                f'<a class="solo-preview-btn" href="{solo_url}" target="_blank" rel="noopener" title="Open this item on its own page">'
                f'<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" fill="none" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/></svg>'
                f'Solo Preview</a>'
            )

        # Source view toggle for non-read-only items
        source_btn = ""
        if not read_only:
            source_btn = f'''
          <button class="source-btn" data-slug="{section_id}" onclick="toggleSource('{section_id}')">
            <svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" fill="none" stroke-width="2"><path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/></svg>
            Source
          </button>'''

        sections.append(f'''
    <div class="page-section" id="{section_id}" data-type="{item["type"]}" data-id="{item["id"]}">
      <div class="page-header">
        <div class="page-header-top">
          <div class="page-breadcrumb">
            <label class="approval-checkbox" title="Approve for push">
              <input type="checkbox" class="page-approve-cb" data-slug="{section_id}" data-type="{item['type']}" data-id="{item['id']}" onchange="updateApprovalState()">
              <span class="cb-custom"></span>
            </label>
            <span class="breadcrumb-module">{html_mod.escape(mod_label)}</span>
            <span class="breadcrumb-sep">&#8250;</span>
            <span class="breadcrumb-type" style="color:{color}">{html_mod.escape(type_label)}</span>
            {ro_badge}
          </div>
          <div class="page-counter">{item_num} of {total}</div>
        </div>
        <h2 class="page-title">{html_mod.escape(title)}</h2>
        <div class="page-meta">
          <code class="page-slug">{item["slug"]}</code>
          <span class="page-size">{size_kb:.1f} KB</span>
          <span class="page-modified">Modified {modified}</span>
          {solo_btn}
          {source_btn}
          {'<button class="delete-section-btn" title="Remove from staging" onclick="deleteSection(&#39;' + item["type"] + '&#39;, &#39;' + item["id"] + '&#39;, this)"><svg viewBox="0 0 24 24"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14M10 11v6M14 11v6"/></svg>Remove</button>' if not read_only else ''}
        </div>
      </div>
      {pf_banner}
      <div class="page-canvas-frame">
        <div class="show-content user_content" data-slug="{section_id}" data-type="{item['type']}" data-id="{item['id']}">
          {content_html}
        </div>
        <div class="source-view" data-slug="{section_id}" style="display:none">
          <div class="source-toolbar">
            <span class="source-label">HTML Source</span>
            <button class="source-copy-btn" onclick="copySource('{section_id}')" title="Copy HTML">Copy</button>
            <button class="source-save-btn" onclick="saveSource('{section_id}')" title="Save changes">Save</button>
          </div>
          <textarea class="source-editor" data-slug="{section_id}" spellcheck="false"></textarea>
        </div>
      </div>
    </div>''')

    # Assemble full HTML
    return _build_review_html(
        course_name, course_id, timestamp, total, toc_entries, sections,
        other_courses, serving_root,
    )


def _get_staging_token() -> str:
    """Return the staging server's synchronizer token meta tag, or empty string.

    Lazy-imports staging_server inside this function to avoid a circular import
    (staging_server already imports unified_staging_review at module level). The
    lazy import is safe because by the time _build_review_html is called the
    staging_server module is already fully loaded and on sys.modules.
    """
    try:
        import staging_server as _ss  # lazy — avoids circular import at module top
        tok = _ss._get_session_token()
        if tok is not None:
            return f'  <meta name="asucb-staging-token" content="{html_mod.escape(tok)}">\n'
    except (ImportError, AttributeError):
        pass
    return ""


def _build_review_html(course_name, course_id, timestamp, total, toc_entries,
                       sections, other_courses=None, serving_root=None):
    """Build the full review HTML document with all CSS and JS."""
    safe_serving_root = html_mod.escape(str(serving_root or COURSES_DIR))
    # Build course switcher dropdown options
    switcher_options = ""
    if other_courses:
        options = "".join(
            f'<option value="/courses/{c["id"]}/review">{html_mod.escape(c["name"])} ({c["count"]})</option>'
            for c in other_courses
        )
        switcher_options = f'''
        <select class="top-bar-select" onchange="if(this.value) window.location.href=this.value">
          <option value="">Switch course...</option>
          {options}
        </select>'''
    _staging_tok_meta = _get_staging_token()
    return f'''<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{html_mod.escape(course_name)}</title>
{_staging_tok_meta}  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Roboto:wght@300;400;500;700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
  <style>
    :root {{
      --canvas-bg: #e8e8e8; --canvas-surface: #ffffff; --canvas-text: #273540;
      --canvas-text-secondary: #6a7883; --canvas-link: #0374b5; --canvas-border: #c7cdd1;
      --canvas-table-head: #f5f5f5; --canvas-code-bg: #f8f8f8;
      --asu-maroon: #8C1D40; --asu-gold: #FFC627; --sidebar-width: 280px;
      --correct: #16a34a; --correct-bg: #f0fdf4; --incorrect: #dc2626; --incorrect-bg: #fef2f2;
      --tag-bg: #ede9fe; --tag-text: #5b21b6;
    }}
    *, *::before, *::after {{ box-sizing: border-box; }}
    body {{ margin: 0; padding: 0; font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.6; color: var(--canvas-text); background: var(--canvas-bg); }}
    .top-bar, .sidebar, .action-bar {{ margin: 0; padding: 0; }}

    /* Canvas content area: match Canvas LMS user_content rendering */
    .show-content {{ font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 1rem; line-height: 1.5; color: var(--canvas-text); }}
    .show-content div {{ margin: 0; }}
    .show-content p {{ margin: 0 0 1rem; }}
    .show-content h1, .show-content h2, .show-content h3, .show-content h4, .show-content h5, .show-content h6 {{ margin: 0 0 0.5rem; line-height: 1.2; }}
    .show-content ul, .show-content ol {{ margin: 0 0 1rem; padding-left: 2rem; }}
    .show-content li {{ margin: 0.25em 0; }}
    .show-content strong {{ font-weight: 700; }}
    .show-content em {{ font-style: italic; }}
    .show-content a {{ color: var(--canvas-link); text-decoration: none; }}
    .show-content a:hover {{ text-decoration: underline; }}
    .show-content img {{ max-width: 100%; height: auto; display: inline-block; }}
    .show-content table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
    .show-content th, .show-content td {{ border: 1px solid var(--canvas-border); padding: 8px 12px; text-align: left; }}
    .show-content th {{ background: var(--canvas-table-head); font-weight: 500; }}
    /* A <tr> with an inline background should color the whole row: make its
       cells transparent so the row background shows through (beats .show-content th). */
    .show-content tr[style*="background"] > th,
    .show-content tr[style*="background"] > td {{ background: transparent; }}
    .show-content blockquote {{ border-left: 4px solid var(--canvas-border); margin: 1em 0; padding: 0.5em 1em; color: var(--canvas-text-secondary); }}
    .show-content code {{ background: var(--canvas-code-bg); padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }}
    .show-content pre {{ background: var(--canvas-code-bg); padding: 12px; border-radius: 4px; overflow-x: auto; margin: 1em 0; }}
    .show-content hr {{ border: none; border-top: 1px solid var(--canvas-border); margin: 1.5em 0; }}
    .show-content iframe {{ max-width: 100%; border: 1px solid var(--canvas-border); border-radius: 4px; }}

    /* Top Bar */
    .top-bar {{ position: fixed; top: 0; left: 0; right: 0; height: 52px; background: var(--asu-maroon); color: white; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; z-index: 1000; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }}
    .top-bar-title {{ font-weight: 500; font-size: 15px; display: flex; align-items: center; gap: 10px; }}
    .top-bar-stats {{ font-size: 13px; opacity: 0.85; display: flex; gap: 20px; }}
    .top-bar-btn {{ background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25); color: white; padding: 5px 14px; border-radius: 4px; font-size: 13px; cursor: pointer; font-family: inherit; text-decoration: none; }}
    .top-bar-btn:hover {{ background: rgba(255,255,255,0.25); }}
    a.top-bar-btn, a.top-bar-btn:visited {{ color: white !important; text-decoration: none !important; }}
    .top-bar-select {{ background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25); color: white; padding: 5px 10px; border-radius: 4px; font-size: 13px; cursor: pointer; font-family: inherit; }}
    .top-bar-select option {{ color: #2d3b45; background: white; }}

    /* Scroll Progress */
    .scroll-progress {{ position: fixed; top: 52px; left: var(--sidebar-width); right: 0; height: 3px; z-index: 999; background: #eee; }}
    .scroll-progress-bar {{ height: 100%; background: var(--asu-gold); width: 0%; transition: width 0.1s; }}

    /* Sidebar */
    .sidebar {{ position: fixed; top: 52px; left: 0; bottom: 0; width: var(--sidebar-width); background: #fff; border-right: 1px solid var(--canvas-border); overflow-y: auto; z-index: 900; padding: 16px 0; }}
    .sidebar-header {{ padding: 0 16px 12px; border-bottom: 1px solid #eee; margin-bottom: 8px; }}
    .sidebar-header h3 {{ font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--canvas-text-secondary); }}
    .sidebar-search {{ width: calc(100% - 32px); margin: 8px 16px; padding: 7px 10px; border: 1px solid var(--canvas-border); border-radius: 4px; font-size: 13px; font-family: inherit; outline: none; }}
    .sidebar-search:focus {{ border-color: var(--canvas-link); }}

    .toc-module {{ margin-bottom: 4px; }}
    .toc-module-header {{ padding: 8px 16px; font-size: 13px; font-weight: 500; color: var(--canvas-text); cursor: pointer; display: flex; align-items: center; gap: 6px; user-select: none; }}
    .toc-module-header:hover {{ background: #f7f7f7; }}
    .toc-arrow {{ font-size: 10px; transition: transform 0.15s; color: var(--canvas-text-secondary); }}
    .toc-module.collapsed .toc-arrow {{ transform: rotate(-90deg); }}
    .toc-module.collapsed .toc-pages {{ display: none; }}
    .toc-mod-label {{ background: var(--asu-maroon); color: white; font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 600; }}
    .toc-pages {{ padding: 2px 0 6px; }}
    .toc-page {{ display: flex; align-items: center; gap: 8px; padding: 5px 16px 5px 38px; font-size: 12.5px; color: var(--canvas-text); text-decoration: none; line-height: 1.3; }}
    .toc-page:hover {{ background: #f0f4ff; color: var(--canvas-link); }}
    .toc-page.active {{ background: #e8f0fe; color: var(--canvas-link); font-weight: 500; }}
    .toc-type-dot {{ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }}
    .toc-subheader {{ padding: 7px 18px 6px 38px; color: #64748b; font-size: 11.5px; font-style: italic; line-height: 1.25; text-align: center; }}
    .toc-subheader span {{ display: inline-flex; align-items: center; gap: 8px; max-width: 100%; }}
    .toc-subheader span::before, .toc-subheader span::after {{ content: ""; width: 18px; height: 1px; background: #cbd5e1; }}

    /* Preflight Badges (TOC) */
    .pf-badge {{ font-size: 10px; font-weight: 700; min-width: 16px; height: 16px; line-height: 16px; text-align: center; border-radius: 8px; padding: 0 4px; margin-left: auto; flex-shrink: 0; }}
    .pf-error {{ background: #ffcdd2; color: #c62828; }}
    .pf-warn {{ background: #fff3e0; color: #e65100; }}
    .pf-info {{ background: #e3f2fd; color: #1565c0; }}
    .pf-pass {{ background: #e8f5e9; color: #2e7d32; font-size: 9px; }}

    /* Preflight Banner (inline) */
    .pf-banner {{ background: #fff8e1; padding: 10px 14px; margin: 0 24px 12px; border-radius: 4px; font-size: 13px; }}
    .pf-banner-header {{ display: flex; justify-content: space-between; align-items: center; cursor: pointer; }}
    .pf-banner .pf-issues {{ display: none; margin-top: 8px; }}
    .pf-banner.pf-expanded .pf-issues {{ display: block !important; }}
    .pf-banner.pf-expanded .pf-toggle {{ transform: rotate(90deg); }}
    .pf-toggle {{ font-size: 11px; color: #666; transition: transform 0.15s; }}
    .pf-issue {{ display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f0e6c8; }}
    .pf-issue:last-child {{ border-bottom: none; }}
    .pf-icon {{ flex-shrink: 0; font-size: 14px; }}
    .pf-msg {{ font-weight: 500; color: #333; }}
    .pf-loc {{ font-size: 11px; color: #888; font-family: 'JetBrains Mono', monospace; }}
    .pf-hint {{ font-size: 12px; color: #555; margin-top: 2px; font-style: italic; }}
    .pf-error .pf-msg {{ color: #c62828; }}
    .pf-warn .pf-msg {{ color: #e65100; }}

    /* Main */
    .main {{ margin-left: var(--sidebar-width); margin-top: 52px; padding: 32px 40px 80px; }}

    /* Page Section */
    .page-section {{ background: var(--canvas-surface); border: 1px solid var(--canvas-border); border-radius: 6px; margin-bottom: 32px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); scroll-margin-top: 68px; }}
    .staging-subheader-divider {{ display: flex; align-items: center; justify-content: center; gap: 14px; margin: 8px 0 22px; color: #64748b; font-size: 14px; font-style: italic; scroll-margin-top: 68px; }}
    .staging-subheader-divider::before, .staging-subheader-divider::after {{ content: ""; height: 1px; background: #cbd5e1; flex: 1; max-width: 220px; }}
    .page-header {{ padding: 16px 24px 12px; background: #fafbfc; border-radius: 6px 6px 0 0; border-bottom: 1px solid #eee; }}
    .page-header-top {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }}
    .page-breadcrumb {{ display: flex; align-items: center; gap: 8px; font-size: 13px; }}
    .breadcrumb-module {{ background: var(--asu-maroon); color: white; padding: 2px 10px; border-radius: 3px; font-weight: 600; font-size: 12px; }}
    .breadcrumb-sep {{ color: #ccc; font-size: 16px; }}
    .breadcrumb-type {{ font-weight: 500; }}
    .page-counter {{ font-size: 12px; color: var(--canvas-text-secondary); font-weight: 500; }}
    .page-title {{ font-size: 1.3em; font-weight: 500; color: var(--canvas-text); margin: 4px 0 8px; }}
    .page-meta {{ display: flex; gap: 16px; align-items: center; font-size: 12px; color: var(--canvas-text-secondary); }}
    .page-slug {{ font-family: 'JetBrains Mono', monospace; font-size: 11.5px; background: var(--canvas-code-bg); padding: 2px 8px; border-radius: 3px; border: 1px solid #e5e5e5; }}
    .ro-badge {{ font-size: 10px; background: #f3f4f6; color: #6b7280; padding: 2px 8px; border-radius: 10px; font-weight: 500; margin-left: 8px; }}

    /* Canvas Content Frame */
    .page-canvas-frame {{ padding: 24px 32px 32px; max-width: 100%; overflow-x: auto; }}
    /* show-content styles defined above in Canvas content area section */

    /* Quiz cards (inline) */
    .quiz-inline-header {{ background: #f8f9fa; border-bottom: 1px solid var(--canvas-border); padding: 12px 16px; font-size: 14px; }}
    .quiz-inline-meta {{ font-size: 12px; color: var(--canvas-text-secondary); margin-left: 12px; }}
    .question-card {{ border: 1px solid var(--canvas-border); border-radius: 6px; margin: 12px 0; overflow: hidden; }}
    .question-header {{ padding: 12px 16px; border-bottom: 1px solid var(--canvas-border); display: flex; align-items: center; justify-content: space-between; background: #fafbfc; }}
    .question-header h3 {{ font-size: 13px; font-weight: 500; }}
    .question-tags {{ display: flex; gap: 4px; }}
    .tag {{ font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.3px; }}
    .tag-type {{ background: #e0f2fe; color: #075985; }}
    .tag-blooms {{ background: var(--tag-bg); color: var(--tag-text); }}
    .tag-points {{ background: #fef3c7; color: #92400e; }}
    .question-body {{ padding: 14px 16px; }}
    .question-text {{ margin-bottom: 12px; font-size: 14px; }}
    .answer-list {{ list-style: none; padding: 0; }}
    .answer-item {{ padding: 8px 10px; margin-bottom: 4px; border: 1px solid var(--canvas-border); border-radius: 4px; display: flex; align-items: flex-start; gap: 8px; font-size: 13px; }}
    .answer-item.correct {{ border-color: var(--correct); background: var(--correct-bg); }}
    .answer-marker {{ width: 18px; height: 18px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; }}
    .answer-marker.correct {{ background: var(--correct); color: white; }}
    .answer-marker.incorrect {{ background: #e5e7eb; color: #9ca3af; }}
    .answer-content {{ flex: 1; }}
    .answer-comment {{ font-size: 11px; color: var(--canvas-text-secondary); margin-top: 3px; font-style: italic; }}
    .feedback-section {{ margin-top: 10px; padding: 8px 12px; border-radius: 4px; font-size: 12px; }}
    .feedback-correct {{ background: var(--correct-bg); border: 1px solid #bbf7d0; }}
    .feedback-incorrect {{ background: var(--incorrect-bg); border: 1px solid #fecaca; }}
    .feedback-label {{ font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 3px; }}
    .feedback-correct .feedback-label {{ color: var(--correct); }}
    .feedback-incorrect .feedback-label {{ color: var(--incorrect); }}
    .matching-table {{ width: 100%; border-collapse: collapse; }}
    .matching-table th, .matching-table td {{ padding: 6px 10px; border: 1px solid var(--canvas-border); font-size: 13px; }}
    .matching-table th {{ background: #f5f5f5; font-weight: 500; font-size: 11px; text-transform: uppercase; }}

    /* Back to Top */
    .back-to-top {{ position: fixed; bottom: 24px; right: 24px; width: 44px; height: 44px; border-radius: 50%; background: var(--asu-maroon); color: white; border: none; cursor: pointer; display: none; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.2); font-size: 20px; z-index: 999; }}
    .back-to-top.visible {{ display: flex; }}
    .back-to-top:hover {{ background: #6a1530; }}

    /* Print */
    @media print {{ .sidebar, .top-bar, .scroll-progress, .back-to-top {{ display: none !important; }} .main {{ margin-left: 0; margin-top: 0; padding: 0; }} .page-section {{ break-inside: avoid; box-shadow: none; }} }}
    @media (max-width: 900px) {{ .sidebar {{ display: none; }} .main {{ margin-left: 0; }} .scroll-progress {{ left: 0; }} }}

    /* ── Approval Checkboxes ── */
    .approval-checkbox {{ display: flex; align-items: center; cursor: pointer; margin-right: 4px; }}
    .approval-checkbox input {{ display: none; }}
    .cb-custom {{ width: 20px; height: 20px; border: 2px solid var(--canvas-border); border-radius: 4px; display: flex; align-items: center; justify-content: center; transition: all 0.15s; background: white; }}
    .cb-custom:hover {{ border-color: var(--canvas-link); }}
    .approval-checkbox input:checked + .cb-custom {{ background: #1e7e34; border-color: #1e7e34; }}
    .approval-checkbox input:checked + .cb-custom::after {{ content: '\\2713'; color: white; font-size: 14px; font-weight: 700; }}
    .page-section.approved {{ border-color: #1e7e34; box-shadow: 0 0 0 2px rgba(30,126,52,0.15), 0 1px 4px rgba(0,0,0,0.06); }}
    .page-section.approved .page-header {{ background: #f0faf3; border-bottom-color: #c8e6c9; }}

    /* ── Sticky Action Bar ── */
    .action-bar {{ position: fixed; bottom: -80px; left: var(--sidebar-width); right: 0; height: 64px; background: white; border-top: 2px solid var(--asu-maroon); display: flex; align-items: center; justify-content: space-between; padding: 0 32px; z-index: 1000; box-shadow: 0 -4px 12px rgba(0,0,0,0.1); transition: bottom 0.25s ease; }}
    .action-bar.visible {{ bottom: 0; }}
    .action-bar-left {{ display: flex; align-items: center; gap: 16px; }}
    .action-bar-count {{ font-size: 14px; font-weight: 500; color: var(--canvas-text); }}
    .action-bar-count strong {{ color: #1e7e34; font-size: 18px; }}
    .action-bar-select-btn {{ background: none; border: 1px solid var(--canvas-border); color: var(--canvas-text-secondary); padding: 4px 10px; border-radius: 3px; font-size: 12px; cursor: pointer; font-family: inherit; }}
    .action-bar-select-btn:hover {{ background: #f5f5f5; }}
    .action-bar-right {{ display: flex; align-items: center; gap: 12px; }}
    .copy-btn {{ background: var(--canvas-link); color: white; border: none; padding: 8px 16px; border-radius: 5px; font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit; }}
    .copy-btn:hover {{ background: #025d91; }}

    /* ── Toast ── */
    .toast {{ position: fixed; top: 68px; right: 24px; background: #1e7e34; color: white; padding: 12px 24px; border-radius: 6px; font-size: 14px; font-weight: 500; z-index: 2000; opacity: 0; transform: translateY(-10px); transition: all 0.3s; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }}
    .toast.show {{ opacity: 1; transform: translateY(0); }}
    .toast.error {{ background: #c62828; }}
    .toast.info {{ background: #0374b5; }}

    /* ── Source View ── */
    .source-btn {{ display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px solid var(--canvas-border); color: var(--canvas-text-secondary); padding: 3px 10px; border-radius: 3px; font-size: 11px; font-weight: 500; cursor: pointer; font-family: inherit; margin-left: 8px; }}
    .source-btn:hover {{ background: #f5f5f5; color: var(--canvas-text); border-color: #aaa; }}
    .source-btn.active {{ background: #1a1a2e; color: #e0e0e0; border-color: #1a1a2e; }}
    .solo-preview-btn {{ display: inline-flex; align-items: center; gap: 4px; text-decoration: none; background: none; border: 1px solid var(--canvas-border); color: var(--canvas-text-secondary); padding: 3px 10px; border-radius: 3px; font-size: 11px; font-weight: 500; font-family: inherit; margin-left: 8px; }}
    .solo-preview-btn:hover {{ background: #f5f5f5; color: var(--canvas-text); border-color: #aaa; }}
    .solo-preview-btn:focus-visible {{ outline: 2px solid #8C1D40; outline-offset: 1px; }}
    .source-view {{ display: none; }}
    .source-toolbar {{ display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #1a1a2e; border-radius: 6px 6px 0 0; }}
    .source-label {{ font-size: 11px; font-weight: 600; color: #8a8fb5; text-transform: uppercase; letter-spacing: 0.05em; flex: 1; }}
    .source-copy-btn, .source-save-btn {{ background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15); color: #ccc; padding: 3px 12px; border-radius: 3px; font-size: 11px; cursor: pointer; font-family: inherit; }}
    .source-copy-btn:hover {{ background: rgba(255,255,255,0.2); color: white; }}
    .source-save-btn {{ background: rgba(30,126,52,0.3); border-color: rgba(30,126,52,0.4); color: #6fd88e; }}
    .source-save-btn:hover {{ background: rgba(30,126,52,0.5); color: white; }}
    .source-editor {{ width: 100%; min-height: 300px; max-height: 600px; padding: 16px; background: #12122a; color: #d4d4d4; border: none; border-radius: 0 0 6px 6px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12.5px; line-height: 1.7; resize: vertical; outline: none; tab-size: 2; white-space: pre; overflow-x: auto; }}
    .source-editor:focus {{ box-shadow: inset 0 0 0 1px rgba(3,116,181,0.4); }}

    /* ── Per-Section Delete ── */
    .delete-section-btn {{ display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px solid transparent; color: #bbb; padding: 2px 8px; border-radius: 3px; font-size: 11px; cursor: pointer; font-family: inherit; opacity: 0; transition: opacity 0.15s, color 0.15s, border-color 0.15s, background 0.15s; margin-left: auto; }}
    .page-section:hover .delete-section-btn {{ opacity: 1; }}
    .delete-section-btn:hover {{ background: #fde8e8; border-color: #c62828; color: #c62828; }}
    .delete-section-btn svg {{ width: 13px; height: 13px; stroke: currentColor; fill: none; stroke-width: 1.5; }}

    /* ── Module Select Dialog ── */
    .module-dialog {{ border: 1px solid #ccc; border-radius: 8px; padding: 24px; min-width: 300px; box-shadow: 0 8px 32px rgba(0,0,0,0.15); }}
    .module-dialog::backdrop {{ background: rgba(0,0,0,0.3); }}
    .module-dialog h3 {{ margin: 0 0 12px; font-size: 15px; color: #2d3b45; }}
    .module-dialog-list {{ display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; max-height: 300px; overflow-y: auto; }}
    .module-dialog-item {{ display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid #e5e5e5; border-radius: 5px; cursor: pointer; font-size: 13px; }}
    .module-dialog-item:hover {{ background: #f0f4ff; border-color: var(--canvas-link); }}
    .module-dialog-item .mod-badge {{ background: var(--asu-maroon); color: white; font-size: 10px; padding: 2px 8px; border-radius: 3px; font-weight: 600; }}
  </style>
</head>
<body data-serving-root="{safe_serving_root}">
<div class="top-bar">
  <div class="top-bar-title">
    <a href="/" class="top-bar-btn" title="Back to all courses" style="padding:5px 10px;margin-right:8px;">&#8592;</a>
    {html_mod.escape(course_name)}
  </div>
  <div class="top-bar-stats">
    <span><span id="stagedTotal">{total}</span> staged items</span>
    <span>{timestamp}</span>
    <span title="Directory this preview server reads staged files from">Root: {safe_serving_root}</span>
  </div>
  <div style="display:flex;gap:8px;align-items:center">
    {switcher_options}
    <button class="top-bar-btn" onclick="window.print()">Print</button>
  </div>
</div>

<div class="scroll-progress"><div class="scroll-progress-bar" id="progressBar"></div></div>

<nav class="sidebar">
  <div class="sidebar-header">
    <h3>Staged Content</h3>
  </div>
  <input type="text" class="sidebar-search" placeholder="Filter items..." id="tocSearch">
  {"".join(toc_entries)}
</nav>

<main class="main">
  {"".join(sections)}
</main>

<!-- Action Bar (slides up when items approved) -->
<div class="action-bar" id="actionBar">
  <div class="action-bar-left">
    <div class="action-bar-count"><strong id="approvedCount">0</strong> of <span id="approvedDenom">{total}</span> approved</div>
    <div style="display:flex;gap:6px">
      <button class="action-bar-select-btn" onclick="selectAll()">Select All</button>
      <button class="action-bar-select-btn" onclick="deselectAll()">Deselect All</button>
      <button class="action-bar-select-btn" onclick="showModuleSelect()">Select by Module</button>
    </div>
  </div>
  <div class="action-bar-right">
    <button class="copy-btn" onclick="copySlugs()">Copy Slugs</button>
  </div>
</div>

<!-- Module Select Dialog -->
<dialog class="module-dialog" id="moduleDialog">
  <h3>Select by Module</h3>
  <div class="module-dialog-list" id="moduleDialogList"></div>
  <div style="display:flex;gap:8px;justify-content:flex-end">
    <button onclick="document.getElementById('moduleDialog').close()" style="padding:6px 16px;border:1px solid #ccc;border-radius:4px;background:white;cursor:pointer;font-family:inherit">Cancel</button>
  </div>
</dialog>

<!-- Toast -->
<div class="toast" id="toast"></div>

<button class="back-to-top" id="backToTop" title="Back to top">&uarr;</button>

<script>
(function() {{
  // Scroll-spy
  var sections = document.querySelectorAll('.page-section');
  var tocLinks = document.querySelectorAll('.toc-page');
  if (sections.length) {{
    var observer = new IntersectionObserver(function(entries) {{
      entries.forEach(function(entry) {{
        if (entry.isIntersecting) {{
          tocLinks.forEach(function(n) {{ n.classList.remove('active'); }});
          var a = document.querySelector('.toc-page[href="#' + entry.target.id + '"]');
          if (a) a.classList.add('active');
        }}
      }});
    }}, {{ threshold: 0.15, rootMargin: '-80px 0px -60% 0px' }});
    sections.forEach(function(s) {{ observer.observe(s); }});
  }}

  // Scroll progress
  window.addEventListener('scroll', function() {{
    var h = document.documentElement.scrollHeight - window.innerHeight;
    if (h > 0) document.getElementById('progressBar').style.width = (window.scrollY / h * 100) + '%';
  }});

  // Back to top
  var btn = document.getElementById('backToTop');
  window.addEventListener('scroll', function() {{ btn.classList.toggle('visible', window.scrollY > 400); }});
  btn.addEventListener('click', function() {{ window.scrollTo({{ top: 0, behavior: 'smooth' }}); }});

  // TOC search
  document.getElementById('tocSearch').addEventListener('input', function() {{
    var q = this.value.toLowerCase();
    document.querySelectorAll('.toc-module').forEach(function(mod) {{
      var pages = mod.querySelectorAll('.toc-page, .toc-subheader');
      var has = false;
      pages.forEach(function(p) {{
        var m = p.textContent.toLowerCase().indexOf(q) !== -1;
        p.style.display = m || !q ? '' : 'none';
        if (m) has = true;
      }});
      mod.style.display = has || !q ? '' : 'none';
      if (q && has) mod.classList.remove('collapsed');
    }});
  }});

  // Collapsible modules
  document.querySelectorAll('.toc-module-header').forEach(function(h) {{
    h.addEventListener('click', function() {{ this.parentElement.classList.toggle('collapsed'); }});
  }});

  // Keyboard nav
  document.addEventListener('keydown', function(e) {{
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
    var secs = Array.from(document.querySelectorAll('.page-section'));
    var current = -1;
    secs.forEach(function(s, i) {{ if (s.getBoundingClientRect().top < 200) current = i; }});
    if (e.key === 'j' || e.key === 'J') {{
      if (current < secs.length - 1) secs[current + 1].scrollIntoView({{ behavior: 'smooth', block: 'start' }});
    }} else if (e.key === 'k' || e.key === 'K') {{
      if (current > 0) secs[current - 1].scrollIntoView({{ behavior: 'smooth', block: 'start' }});
    }} else if (e.key === ' ' && !e.ctrlKey && !e.metaKey) {{
      // Space = toggle approval on current section
      if (current >= 0) {{
        var cb = secs[current].querySelector('.page-approve-cb');
        if (cb) {{ cb.checked = !cb.checked; updateApprovalState(); e.preventDefault(); }}
      }}
    }} else if (e.key === 'a' && !e.ctrlKey && !e.metaKey) {{
      selectAll(); e.preventDefault();
    }} else if (e.key === 'd' && !e.ctrlKey && !e.metaKey) {{
      deselectAll(); e.preventDefault();
    }}
  }});

  // ── Approval System ──
  window.updateApprovalState = function() {{
    var cbs = document.querySelectorAll('.page-approve-cb');
    var approved = 0;
    cbs.forEach(function(cb) {{
      var section = document.getElementById(cb.dataset.slug);
      if (cb.checked) {{
        approved++;
        if (section) section.classList.add('approved');
      }} else {{
        if (section) section.classList.remove('approved');
      }}
    }});
    document.getElementById('approvedCount').textContent = approved;
    var bar = document.getElementById('actionBar');
    bar.classList.toggle('visible', approved > 0);
    // Save to localStorage
    var slugs = [];
    cbs.forEach(function(cb) {{ if (cb.checked) slugs.push(cb.dataset.slug); }});
    localStorage.setItem('asucb_approved_{course_id}', JSON.stringify(slugs));
  }};

  window.selectAll = function() {{
    document.querySelectorAll('.page-approve-cb').forEach(function(cb) {{ cb.checked = true; }});
    updateApprovalState();
  }};

  window.deselectAll = function() {{
    document.querySelectorAll('.page-approve-cb').forEach(function(cb) {{ cb.checked = false; }});
    updateApprovalState();
  }};

  // Restore approvals from localStorage
  (function() {{
    try {{
      var saved = JSON.parse(localStorage.getItem('asucb_approved_{course_id}') || '[]');
      if (saved.length) {{
        saved.forEach(function(slug) {{
          var cb = document.querySelector('.page-approve-cb[data-slug="' + slug + '"]');
          if (cb) cb.checked = true;
        }});
        updateApprovalState();
      }}
    }} catch(e) {{}}
  }})();

  // ── Toast ──
  window.showToast = function(msg, type) {{
    var t = document.getElementById('toast');
    t.textContent = msg;
    t.className = 'toast ' + (type || '');
    setTimeout(function() {{ t.classList.add('show'); }}, 10);
    setTimeout(function() {{ t.classList.remove('show'); }}, 4000);
  }};

  // ── HTML Prettifier ──
  function prettifyHtml(html) {{
    // Split tags onto separate lines, then indent
    // Step 1: Insert newlines between adjacent tags (><)
    var s = html.replace(/></g, '>\\n<');
    // Step 2: Also break after self-closing and before opening tags
    s = s.replace(/>([^<]+)</g, '>\\n$1\\n<');
    var raw = s.split('\\n');
    var indent = 0;
    var tab = '  ';
    var out = [];
    var voidRe = /^<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)(\\s|>|\\/)/i;
    for (var i = 0; i < raw.length; i++) {{
      var line = raw[i].trim();
      if (!line) continue;
      // Closing tag: decrease indent first
      if (line.match(/^<\\//)) indent = Math.max(0, indent - 1);
      out.push(tab.repeat(indent) + line);
      // Opening tag (not closing, not self-closing, not void): increase indent
      if (line.match(/^<[a-zA-Z]/) && !line.match(/^<\\//) && !line.match(/\\/>$/) && !voidRe.test(line)) {{
        indent++;
      }}
    }}
    return out.join('\\n');
  }}

  // ── Source Toggle ──
  window.toggleSource = function(slug) {{
    var section = document.getElementById(slug);
    var contentEl = section.querySelector('.show-content[data-slug="' + slug + '"]');
    var sourceView = section.querySelector('.source-view[data-slug="' + slug + '"]');
    var sourceBtn = section.querySelector('.source-btn[data-slug="' + slug + '"]');
    var textarea = section.querySelector('.source-editor[data-slug="' + slug + '"]');

    if (sourceView.style.display === 'none') {{
      // Show source, hide rendered
      var html = contentEl.innerHTML;
      textarea.value = prettifyHtml(html);
      textarea.style.height = Math.min(600, Math.max(200, textarea.scrollHeight)) + 'px';
      contentEl.style.display = 'none';
      sourceView.style.display = 'block';
      sourceBtn.classList.add('active');
      sourceBtn.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" fill="none" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Preview';
    }} else {{
      // Show rendered, hide source
      contentEl.style.display = '';
      sourceView.style.display = 'none';
      sourceBtn.classList.remove('active');
      sourceBtn.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" fill="none" stroke-width="2"><path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/></svg> Source';
    }}
  }};

  window.copySource = function(slug) {{
    var section = document.getElementById(slug);
    var textarea = section.querySelector('.source-editor[data-slug="' + slug + '"]');
    navigator.clipboard.writeText(textarea.value).then(function() {{
      showToast('HTML copied to clipboard');
    }});
  }};

  window.saveSource = function(slug) {{
    var section = document.getElementById(slug);
    var textarea = section.querySelector('.source-editor[data-slug="' + slug + '"]');
    var contentEl = section.querySelector('.show-content[data-slug="' + slug + '"]');
    var type = contentEl.dataset.type;
    var id = contentEl.dataset.id;
    var html = textarea.value;

    var _stagingTok = document.querySelector('meta[name="asucb-staging-token"]')?.content;
    var url = type === 'syllabus'
      ? '/api/staging/{course_id}/syllabus'
      : '/api/staging/{course_id}/' + type + '/' + id;
    fetch(url, {{
      method: 'PUT',
      headers: {{ 'Content-Type': 'application/json', ...(_stagingTok ? {{'X-ASUCB-Staging-Token': _stagingTok}} : {{}}) }},
      body: JSON.stringify({{ html: html }})
    }}).then(function(r) {{ return r.json(); }}).then(function(data) {{
      if (data.ok) {{
        // Update the rendered view with the new HTML
        contentEl.innerHTML = html;
        showToast('Source saved to staging');
      }} else {{
        showToast('Save failed: ' + (data.error || ''), 'error');
      }}
    }}).catch(function(e) {{
      showToast('Save error: ' + e.message, 'error');
    }});
  }};

  // ── Module Select Dialog ──
  window.showModuleSelect = function() {{
    var dialog = document.getElementById('moduleDialog');
    var list = document.getElementById('moduleDialogList');
    // Discover unique modules from checkboxes
    var modules = {{}};
    document.querySelectorAll('.page-approve-cb').forEach(function(cb) {{
      var section = document.getElementById(cb.dataset.slug);
      if (!section) return;
      var modEl = section.querySelector('.breadcrumb-module');
      var modLabel = modEl ? modEl.textContent : 'Unknown';
      if (!modules[modLabel]) modules[modLabel] = [];
      modules[modLabel].push(cb);
    }});
    list.innerHTML = '';
    Object.keys(modules).sort().forEach(function(mod) {{
      var count = modules[mod].length;
      var item = document.createElement('div');
      item.className = 'module-dialog-item';
      var badge = mod.match(/Module\\s*(\\d+)/);
      var badgeText = badge ? 'M' + badge[1] : '?';
      item.innerHTML = '<span class="mod-badge">' + badgeText + '</span>' + mod + ' <span style="color:#999;margin-left:auto">(' + count + ' items)</span>';
      item.addEventListener('click', function() {{
        modules[mod].forEach(function(cb) {{ cb.checked = true; }});
        updateApprovalState();
        dialog.close();
        showToast('Selected ' + count + ' items in ' + mod);
      }});
      list.appendChild(item);
    }});
    dialog.showModal();
  }};

  // ── Delete Section ──
  window.deleteSection = function(type, id, btn) {{
    if (!confirm('Remove ' + type.slice(0, -1) + ' "' + id + '" from staging?')) return;
    var section = btn.closest('.page-section');
    var _stagingTok = document.querySelector('meta[name="asucb-staging-token"]')?.content;
    var delUrl = type === 'syllabus'
      ? '/api/staging/{course_id}/syllabus'
      : '/api/staging/{course_id}/' + type + '/' + id;
    fetch(delUrl, {{
      method: 'DELETE',
      headers: {{ ...(_stagingTok ? {{'X-ASUCB-Staging-Token': _stagingTok}} : {{}}) }}
    }}).then(function(r) {{ return r.json(); }}).then(function(data) {{
      if (data.ok) {{
        section.style.transition = 'opacity 0.3s, max-height 0.4s';
        section.style.opacity = '0';
        section.style.maxHeight = section.scrollHeight + 'px';
        setTimeout(function() {{
          section.style.maxHeight = '0';
          section.style.overflow = 'hidden';
          section.style.margin = '0';
          section.style.padding = '0';
          section.style.border = 'none';
        }}, 100);
        setTimeout(function() {{ section.remove(); updateApprovalState(); }}, 500);
        // Remove from TOC
        var tocLink = document.querySelector('.toc-page[href="#' + type + '-' + id + '"]');
        if (tocLink) tocLink.remove();
        // Update staged-item counts
        var totalEl = document.getElementById('stagedTotal');
        var denomEl = document.getElementById('approvedDenom');
        if (totalEl) totalEl.textContent = Math.max(0, parseInt(totalEl.textContent) - 1);
        if (denomEl) denomEl.textContent = Math.max(0, parseInt(denomEl.textContent) - 1);
        showToast('Removed from staging');
      }} else {{
        showToast('Failed to remove: ' + (data.error || ''), 'error');
      }}
    }}).catch(function(e) {{
      showToast('Delete error: ' + e.message, 'error');
    }});
  }};

  // ── Copy Slugs ──
  window.copySlugs = function() {{
    var slugs = [];
    document.querySelectorAll('.page-approve-cb:checked').forEach(function(cb) {{
      slugs.push(cb.dataset.type + '/' + cb.dataset.id);
    }});
    navigator.clipboard.writeText(slugs.join('\\n')).then(function() {{
      showToast(slugs.length + ' slug(s) copied to clipboard');
    }});
  }};

}})();
</script>

<!-- Staging server is read-only preview. Edits are made surgically via Claude/Codex or Canvas Source mode, then re-staged. -->
<script>/* editor removed — staging writes come from staging_manager.py / push scripts */
</script>
</body>
</html>'''


# ── CLI ─────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="Unified Staging Review")
    parser.add_argument("--course-id", required=True, help="Canvas course ID")
    parser.add_argument("--open", action="store_true", help="Open in browser after generating")
    parser.add_argument("--output", help="Output path (default: courses/{id}/staging/_unified_review.html)")
    args = parser.parse_args()

    html = generate_review(args.course_id)

    output = args.output or str(COURSES_DIR / args.course_id / "staging" / "_unified_review.html")
    Path(output).parent.mkdir(parents=True, exist_ok=True)
    Path(output).write_text(html, encoding="utf-8")
    print(f"Review generated: {output}")

    if args.open:
        subprocess.run(["open", output])


if __name__ == "__main__":
    main()
