#!/usr/bin/env python3
"""Staging preview server — renders staged content in Canvas-like shells.

Serves staged HTML files (pages, assignments, discussions) wrapped in a
Canvas shell template, and staged quiz JSON rendered as interactive question
cards. Also provides a unified review page per course and an index page.

Usage:
    python3 scripts/staging_server.py [--port 8111]

Routes:
    /                                       → Index: all staged items across courses
    /courses/{courseId}/review              → Unified staging review (all items, sidebar TOC)
    /courses/{courseId}/preview/{type}/{key} → Canonical staged preview route
    /courses/{courseId}/preview/syllabus     → Syllabus → unified review (302)
    Legacy course preview aliases remain supported permanently.
    /api/staging/{courseId}/{type}/{key}     → Save/delete/raw/meta staging API
"""

import argparse
import errno
import html
import json
import os
import secrets
import sys
import threading
from dataclasses import dataclass
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from typing import Any, Callable, Literal, Optional
from urllib.parse import unquote

# S4: stdlib-only HTML sanitizer (no pip dependency).
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

# Per-process CSRF synchronizer token.
# Stored in os.environ["ASUCB_STAGING_TOKEN"] so that both the __main__ module
# identity (when run as `python staging_server.py`) and the `staging_server`
# module identity (when lazy-imported by unified_staging_review) read the same
# value.  Before main() runs the env var is absent → _get_session_token()
# returns None, which preserves the "test / library use without a token"
# no-op behaviour.


def _get_session_token() -> Optional[str]:
    """Return the current per-process synchronizer token, or None if unset."""
    return os.environ.get("ASUCB_STAGING_TOKEN")


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

# Ensure scripts dir is on path for unified_staging_review import
sys.path.insert(0, str(PLUGIN_ROOT / "scripts"))

from html_sanitize import sanitize_html  # noqa: E402  (needs the path insert above)

CANVAS_SHELL = TEMPLATE_DIR / "canvas-shell.html"
QUIZ_SHELL = TEMPLATE_DIR / "quiz-shell.html"

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


@dataclass(frozen=True)
class StagingType:
    name: str
    staging_pattern: str
    extension: str
    key_mode: Literal["single", "catchall", "none"]
    resolve: Callable[[str, str], Path]
    load: Callable[[Path], Any]
    wrapper: Callable[[Any, str, str], str]
    validate_save: Callable[[Any], None]
    write: Callable[[Path, Any], None]
    delete_sidecars: bool
    preflight: Optional[Callable[[Path], dict]] = None


@dataclass(frozen=True)
class PreviewRequest:
    course_id: str
    obj_type: str
    key: str = ""
    subroute: str = ""


STAGING_TYPES = {
    "pages": StagingType(
        name="pages",
        staging_pattern="staging/pages/{key}.html",
        extension=".html",
        key_mode="single",
        resolve=lambda course_id, key: _resolve_safe(course_id, "pages", key),
        load=lambda path: _load_text(path),
        wrapper=lambda content, course_id, key: _wrap_html(content, f"Pages {key}", course_id=course_id),
        validate_save=lambda data: _validate_html_save(data),
        write=lambda path, data: _write_html_save(path, data),
        delete_sidecars=True,
        preflight=lambda path: _load_preflight_sidecar(path),
    ),
    "assignments": StagingType(
        name="assignments",
        staging_pattern="staging/assignments/{key}.html",
        extension=".html",
        key_mode="single",
        resolve=lambda course_id, key: _resolve_safe(course_id, "assignments", key),
        load=lambda path: _load_text(path),
        wrapper=lambda content, course_id, key: _wrap_html(content, f"Assignments {key}", course_id=course_id),
        validate_save=lambda data: _validate_html_save(data),
        write=lambda path, data: _write_html_save(path, data),
        delete_sidecars=True,
        preflight=lambda path: _load_preflight_sidecar(path),
    ),
    "discussions": StagingType(
        name="discussions",
        staging_pattern="staging/discussions/{key}.html",
        extension=".html",
        key_mode="single",
        resolve=lambda course_id, key: _resolve_safe(course_id, "discussions", key),
        load=lambda path: _load_text(path),
        wrapper=lambda content, course_id, key: _wrap_html(content, f"Discussions {key}", course_id=course_id),
        validate_save=lambda data: _validate_html_save(data),
        write=lambda path, data: _write_html_save(path, data),
        delete_sidecars=True,
        preflight=lambda path: _load_preflight_sidecar(path),
    ),
    "quizzes": StagingType(
        name="quizzes",
        staging_pattern="staging/quizzes/{key}.json",
        extension=".json",
        key_mode="single",
        resolve=lambda course_id, key: _resolve_safe(course_id, "quizzes", key),
        load=lambda path: _load_json(path),
        wrapper=lambda data, course_id, key: _render_quiz(data, key, course_id=course_id, obj_id=key),
        validate_save=lambda data: _validate_json_object_save(data),
        write=lambda path, data: _write_json_save(path, data),
        delete_sidecars=True,
        preflight=lambda path: _load_preflight_sidecar(path),
    ),
    "interactives": StagingType(
        name="interactives",
        staging_pattern="staging/interactives/{key}.html",
        extension=".html",
        key_mode="catchall",
        resolve=lambda course_id, key: _resolve_safe(course_id, "interactives", key),
        load=lambda path: _load_text(path),
        wrapper=lambda content, course_id, key: _wrap_interactive_preview(content, course_id, key),
        validate_save=lambda data: _validate_html_save(data),
        write=lambda path, data: _write_html_save(path, data),
        delete_sidecars=True,
        preflight=lambda path: _load_preflight_sidecar(path),
    ),
    "rubrics": StagingType(
        name="rubrics",
        staging_pattern="staging/rubrics/{key}.html",
        extension=".html",
        key_mode="single",
        resolve=lambda course_id, key: _resolve_safe(course_id, "rubrics", key),
        load=lambda path: _load_text(path),
        wrapper=lambda content, course_id, key: _wrap_html(
            content, f"Rubrics {key}", course_id=course_id
        ),
        validate_save=lambda data: _validate_html_save(data),
        write=lambda path, data: _write_html_save(path, data),
        delete_sidecars=True,
        preflight=None,
    ),
    "syllabus": StagingType(
        name="syllabus",
        staging_pattern="staging/syllabus/syllabus_body.html",
        extension=".html",
        key_mode="none",
        resolve=lambda course_id, key: _resolve_syllabus_safe(course_id),
        load=lambda path: _load_text(path),
        wrapper=lambda content, course_id, key: _wrap_html(content, "Syllabus", course_id=course_id),
        validate_save=lambda data: _validate_html_save(data),
        write=lambda path, data: _write_html_save(path, data),
        delete_sidecars=True,
        preflight=lambda path: _load_preflight_sidecar(path),
    ),
}

import mimetypes
import re

COURSE_ID_RE = re.compile(r'^[A-Za-z0-9._-]+$')
OBJ_ID_RE = re.compile(r'^[A-Za-z0-9._-]+$')

# Regex for file keys in the 'files' staging type.
# Must be slug-safe: no path separators, no '..'.
FILE_KEY_RE = re.compile(r'^[A-Za-z0-9._-]+$')

# Token pattern: asucb-staged-file://{key} where key matches FILE_KEY_RE.
_STAGED_FILE_TOKEN_RE = re.compile(r'asucb-staged-file://([A-Za-z0-9._-]+)')

# A well-formed MIME type/subtype. The staged-file sidecar mime is data and may
# be derived from an untrusted source, so it is validated against this before
# being used as a Content-Type header value (blocks CRLF / header injection).
_SAFE_MIME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9.+-]*/[A-Za-z0-9][A-Za-z0-9.+-]*$')


def _request_path(request_path):
    return request_path.split("?", 1)[0].split("#", 1)[0].rstrip("/")


def _request_parts(request_path):
    path = _request_path(request_path).strip("/")
    if not path:
        return []
    return [unquote(part) for part in path.split("/")]


def _resolve_course_dir(course_id):
    raw_course_dir = COURSES_DIR / course_id
    try:
        courses_root = COURSES_DIR.resolve(strict=False)
        course_dir = raw_course_dir.resolve(strict=False)
    except (OSError, RuntimeError):
        raise ValueError("Invalid path component") from None
    if not course_dir.is_relative_to(courses_root):
        raise ValueError("Invalid path component")
    return course_dir


def _resolve_safe(course_id, obj_type, obj_id):
    if not COURSE_ID_RE.fullmatch(course_id or ""):
        raise ValueError("Invalid path component")
    if not OBJ_ID_RE.fullmatch(obj_type or ""):
        raise ValueError("Invalid path component")
    if obj_type not in EXTENSIONS:
        raise ValueError("Invalid path component")

    if obj_type == "interactives":
        rel_id = (obj_id or "").removesuffix(EXTENSIONS[obj_type])
        components = rel_id.split("/")
        if not components or any(not OBJ_ID_RE.fullmatch(part) or part in {".", ".."} for part in components):
            raise ValueError("Invalid path component")
    else:
        if not OBJ_ID_RE.fullmatch(obj_id or "") or obj_id in {".", ".."}:
            raise ValueError("Invalid path component")
        components = [obj_id]

    course_dir = _resolve_course_dir(course_id)
    try:
        candidate = (course_dir / "staging" / obj_type / Path(*components[:-1]) / f"{components[-1]}{EXTENSIONS[obj_type]}").resolve(strict=False)
    except (OSError, RuntimeError):
        raise ValueError("Invalid path component") from None
    if not candidate.is_relative_to(course_dir):
        raise ValueError("Invalid path component")
    return candidate


def _resolve_syllabus_safe(course_id):
    if not COURSE_ID_RE.fullmatch(course_id or ""):
        raise ValueError("Invalid path component")
    course_dir = _resolve_course_dir(course_id)
    try:
        candidate = (course_dir / "staging" / "syllabus" / "syllabus_body.html").resolve(strict=False)
    except (OSError, RuntimeError):
        raise ValueError("Invalid path component") from None
    if not candidate.is_relative_to(course_dir):
        raise ValueError("Invalid path component")
    return candidate


def _load_text(path):
    return path.read_text(encoding="utf-8")


def _load_json(path):
    return json.loads(path.read_text(encoding="utf-8"))


def _validate_html_save(data):
    if not isinstance(data, dict):
        raise ValueError("Invalid JSON")


def _validate_json_object_save(data):
    if not isinstance(data, dict):
        raise ValueError("Invalid JSON")


def _write_html_save(path, data):
    path.write_text(data.get("html", ""), encoding="utf-8")


def _write_json_save(path, data):
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")


def _load_preflight_sidecar(path):
    sidecar = path.parent / (path.name + ".issues.json")
    if not sidecar.exists():
        stem_sidecar = path.parent / (path.stem + ".issues.json")
        sidecar = stem_sidecar
    if not sidecar.exists():
        return {}
    try:
        return json.loads(sidecar.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _key_from_parts(staging_type, parts, key_index):
    if staging_type.key_mode == "none":
        return "" if len(parts) == key_index else None
    if staging_type.key_mode == "single":
        return parts[key_index] if len(parts) == key_index + 1 else None
    if len(parts) <= key_index:
        return None
    return "/".join(parts[key_index:])


def parse_preview_request(parts):
    if len(parts) >= 4 and parts[0] == "courses" and parts[2] == "preview":
        obj_type = parts[3]
        staging_type = STAGING_TYPES.get(obj_type)
        if staging_type is None:
            return None
        key = _key_from_parts(staging_type, parts, 4)
        if key is None:
            return None
        return PreviewRequest(parts[1], obj_type, key)

    if len(parts) >= 3 and parts[0] == "courses":
        obj_type = parts[2]
        staging_type = STAGING_TYPES.get(obj_type)
        if staging_type is None:
            return None
        key = _key_from_parts(staging_type, parts, 3)
        if key is None:
            return None
        return PreviewRequest(parts[1], obj_type, key)

    return None


def parse_api_request(parts, allow_subroute=True):
    if len(parts) < 4 or parts[0] != "api" or parts[1] != "staging":
        return None

    course_id = parts[2]
    obj_type = parts[3]
    staging_type = STAGING_TYPES.get(obj_type)
    if staging_type is None:
        return None

    if staging_type.key_mode == "none":
        if len(parts) == 4:
            return PreviewRequest(course_id, obj_type)
        if allow_subroute and len(parts) == 5 and parts[4] in {"raw", "meta"}:
            return PreviewRequest(course_id, obj_type, subroute=parts[4])
        return None

    if len(parts) < 5:
        return None
    key_parts = parts[4:]
    subroute = ""
    if key_parts[-1] in {"raw", "meta"} and allow_subroute:
        subroute = key_parts[-1]
        key_parts = key_parts[:-1]
    elif key_parts[-1] == "details" and obj_type == "quizzes":
        # "details" subroute is valid on both GET and PUT (additive Details API).
        subroute = key_parts[-1]
        key_parts = key_parts[:-1]
    if not key_parts:
        return None

    if staging_type.key_mode == "single" and len(key_parts) != 1:
        return None
    return PreviewRequest(course_id, obj_type, "/".join(key_parts), subroute=subroute)


def _raw_data(staging_type, staged_file):
    if staging_type.name == "quizzes":
        return _load_json(staged_file)
    return _load_text(staged_file)


def _sidecar_candidates(staged_file, course_id, obj_type, key):
    suffixes = (".issues.json", ".vision.json", ".meta.json")
    course_dir = _resolve_course_dir(course_id)
    type_dir = course_dir / "staging" / obj_type
    candidates = []
    for suffix in suffixes:
        candidates.append(staged_file.parent / (staged_file.name + suffix))
        candidates.append(staged_file.parent / (staged_file.stem + suffix))
        if key:
            candidates.append(type_dir / (key + suffix))
    return candidates


def _delete_with_sidecars(staging_type, staged_file, course_id, key):
    staged_file.unlink()
    if not staging_type.delete_sidecars:
        return
    seen = set()
    for sidecar in _sidecar_candidates(staged_file, course_id, staging_type.name, key):
        if sidecar in seen:
            continue
        seen.add(sidecar)
        if sidecar.exists():
            sidecar.unlink()


def _read_template(path):
    """Read a template file."""
    return path.read_text(encoding="utf-8")


def _rewrite_staged_file_tokens(content, course_id):
    """Replace asucb-staged-file://{key} tokens with /courses/{id}/staging-files/{key}.

    Only well-formed tokens (key matches FILE_KEY_RE, no '..' sequences) are
    rewritten. Malformed tokens are left untouched.
    """
    if not course_id or "asucb-staged-file://" not in content:
        return content

    def replace(match):
        key = match.group(1)
        if not FILE_KEY_RE.fullmatch(key) or key in {".", ".."}:
            return match.group(0)
        safe_course = html.escape(str(course_id), quote=True)
        safe_key = html.escape(key, quote=True)
        return f"/courses/{safe_course}/staging-files/{safe_key}"

    return _STAGED_FILE_TOKEN_RE.sub(replace, content)


def _sandboxed_fragment(content, course_id=""):
    """Render untrusted staged Canvas HTML as an inert, sandboxed iframe.

    Staged bodies are semi-untrusted: an imported cartridge, a prior ID, or a
    faculty edit can put arbitrary HTML/JS in a Canvas page. Injected inline on
    the staging-server origin, that JS could read the synchronizer token out of
    a same-origin page and PUT over any staged artifact -- so the human approves
    one body and pushes another.

    Same two barriers the unified /review route uses:
      1. sanitize_html strips script/on*/javascript: (defense in depth), then
      2. the sandbox attribute -- with neither allow-scripts nor
         allow-same-origin -- gives the frame an opaque origin with scripting
         off, so nothing inside can reach the staging API.

    srcdoc is attribute-escaped so content cannot break out of the attribute.
    Staged-file tokens are rewritten after sanitizing (the sanitizer preserves
    the token URL), so local image previews still resolve.
    """
    safe_body = _rewrite_staged_file_tokens(sanitize_html(content), course_id)
    return (
        f'<iframe sandbox srcdoc="{html.escape(safe_body, quote=True)}" '
        f'style="width:100%;min-height:60vh;border:0;display:block" '
        f'title="Sandboxed content preview"></iframe>'
    )


def _wrap_html(content, title="Staged Content", course_id=""):
    """Wrap raw HTML in the Canvas shell template.

    The preview-top-bar chrome (Back link + title) is injected into the
    {{TOP_BAR}} slot which lives OUTSIDE the .user_content div, so that any
    client-side scrape of .user_content captures only the real staged content
    and can never re-inject chrome into the saved staged file.

    The staged body itself is rendered inert via _sandboxed_fragment; scripts in
    it never execute on this origin.
    """
    content = _sandboxed_fragment(content, course_id)
    shell = _read_template(CANVAS_SHELL)
    top_bar = ""
    if course_id:
        safe_course = html.escape(str(course_id), quote=True)
        safe_title = html.escape(str(title))
        top_bar_css = """
    .preview-top-bar {
      margin: -24px -32px 24px;
      min-height: 56px;
      background: #8C1D40;
      color: #fff;
      display: flex;
      align-items: center;
      gap: 16px;
      padding: 10px 24px;
      border-radius: 4px 4px 0 0;
      flex-wrap: wrap;
    }
    .preview-top-bar .back-btn {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 6px 12px;
      background: #FFC627;
      color: #4A0A24;
      font-size: 13px;
      font-weight: 600;
      text-decoration: none;
      border-radius: 4px;
      transition: background .15s;
    }
    .preview-top-bar .back-btn:hover { background: #ffd454; text-decoration: none; }
    .preview-top-bar .back-btn:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }
    .preview-top-bar .top-title { font-size: 16px; font-weight: 600; }
"""
        shell = shell.replace("    /* Preview label */", top_bar_css + "    /* Preview label */")
        top_bar = f"""<div class="preview-top-bar">
      <a class="back-btn" href="/courses/{safe_course}/review" aria-label="Back to unified course review">← Back to Course Review</a>
      <div class="top-title">{safe_title}</div>
    </div>"""
    return (
        shell.replace("{{TOP_BAR}}", top_bar)
        .replace("{{CONTENT}}", content)
        .replace("{{TITLE}}", html.escape(str(title)))
    )


def _activity_title(rest):
    stem = Path(rest).name.removesuffix(".html")
    return stem.replace("-", " ").replace("_", " ").title() or "Interactive"


def _wrap_interactive_preview(content, course_id, rest):
    """Wrap interactive HTML in an ASU preview shell (customer-facing preview)."""
    title = _activity_title(rest)
    safe_title = html.escape(title)
    safe_course = html.escape(str(course_id))
    srcdoc = html.escape(content, quote=True)
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{safe_title} Interactive Preview</title>
  <style>
    * {{ box-sizing: border-box; }}
    body {{ margin: 0; font-family: Roboto, Arial, sans-serif; color: #273540; background: #f3f4f6; }}
    .top-bar {{ height: 56px; background: #8C1D40; color: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; }}
    .top-left {{ display: flex; align-items: center; gap: 16px; }}
    .top-title {{ font-size: 16px; font-weight: 600; }}
    .top-subtitle {{ font-size: 13px; color: #f6d365; }}
    .back-btn {{ display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: #FFC627; color: #4A0A24; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 4px; transition: background .15s; }}
    .back-btn:hover {{ background: #ffd454; }}
    .back-btn:focus-visible {{ outline: 3px solid #fff; outline-offset: 2px; }}
    .preview {{ padding: 24px; }}
    .frame-wrap {{ background: #fff; border: 1px solid #d1d5db; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.08); resize: vertical; height: calc(100vh - 130px); min-height: 480px; max-width: 1200px; margin: 0 auto; }}
    iframe {{ display: block; width: 100%; height: 100%; border: 0; background: #fff; }}
  </style>
</head>
<body>
  <header class="top-bar">
    <div class="top-left">
      <a class="back-btn" href="/courses/{safe_course}/review" aria-label="Back to unified course review">← Back to Course Review</a>
      <div class="top-title">{safe_title}</div>
    </div>
    <div class="top-subtitle">Interactive Preview</div>
  </header>
  <main class="preview">
    <div class="frame-wrap">
      <iframe title="{safe_title}" sandbox="allow-scripts allow-forms" srcdoc="{srcdoc}"></iframe>
    </div>
  </main>
</body>
</html>"""




def _render_quiz(quiz_data, title="Quiz", course_id="", obj_id=""):
    """Render quiz JSON as HTML question cards with edit-mode controls."""
    shell = _read_template(QUIZ_SHELL)

    quiz = quiz_data.get("quiz", {})
    questions = quiz_data.get("questions", [])

    quiz_title = quiz.get("title") or quiz_data.get("quiz_title") or title
    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")
    shuffle = quiz.get("shuffle_answers", True)
    q_count = len(questions)

    type_label = "Practice Quiz" if quiz_type == "practice_quiz" else "Graded Quiz"

    # Quiz header
    parts = []
    edit_toolbar = ""
    if course_id and obj_id:
        edit_toolbar = f"""
    <div class="edit-toolbar" id="edit-toolbar"
         data-course-id="{html.escape(course_id)}"
         data-obj-id="{html.escape(obj_id)}">
      <a class="back-to-review" href="/courses/{html.escape(course_id)}/review" aria-label="Back to unified course review">← Back to Course Review</a>
      <label class="edit-toggle">
        <input type="checkbox" id="edit-mode-toggle"> Edit mode
      </label>
      <button type="button" id="save-btn" class="save-btn" disabled>Save</button>
      <span id="save-status" class="save-status">Last saved: —</span>
      <span id="dirty-flag" class="dirty-flag" hidden>● unsaved changes</span>
      <span id="stale-flag" class="stale-flag" hidden>⚠ file changed on disk — reload</span>
    </div>"""
    parts.append(f"""
    {edit_toolbar}
    <div class="quiz-header" data-quiz-root>
      <h1>{html.escape(quiz_title)}</h1>
      <div class="quiz-meta">
        <span>{type_label}</span>
        <span>{q_count} questions</span>
        <span>{points} pts</span>
      </div>
    </div>
    <div class="quiz-settings">
      <span>Attempts: {attempts_str}</span>
      <span>Scoring: {scoring.replace('_', ' ')}</span>
      <span>Shuffle: {'Yes' if shuffle else 'No'}</span>
    </div>
    {_render_quiz_tabs_bar()}
    {_render_quiz_details_tab(quiz, course_id=course_id, obj_id=obj_id)}
    <div id="quiz-tab-questions" class="quiz-tab-panel" role="tabpanel" aria-labelledby="quiz-tab-btn-questions">
    {_render_quiz_settings_panel(quiz)}
    {_render_quiz_description_card(quiz, course_id=course_id, obj_id=obj_id)}
    """)

    # Question cards
    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", [])

        # Extract Bloom's level from question name if tagged
        blooms = ""
        for level in ["Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create"]:
            if level.lower() in q_name.lower():
                blooms = level
                break

        # Type display name
        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",
        }
        type_label = type_names.get(q_type, q_type)

        # Tags
        tags_html = f'<span class="tag tag-type">{html.escape(type_label)}</span>'
        if blooms:
            tags_html += f'<span class="tag tag-blooms">{html.escape(blooms)}</span>'
        tags_html += (
            f'<span class="tag tag-points view-only">{q_points} pts</span>'
            f'<span class="tag tag-points edit-only">'
            f'<input type="number" class="points-input" data-q-field="points_possible" '
            f'value="{q_points}" min="0" step="0.5"> pts</span>'
        )

        # Answers
        if q_type == "matching_question":
            answers_html = f'<table class="matching-table" data-q="{i}"><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 "—"
                answers_html += f"""
                <tr data-a="{j}">
                  <td class="editable-cell" data-field="answer_match_left">{html.escape(left)}</td>
                  <td class="editable-cell" data-field="answer_match_right">{html.escape(right)}</td>
                </tr>"""
            answers_html += "</table>"
            for j, a in enumerate(answers):
                comment_html_field = a.get("answer_comments_html")
                comment_raw = a.get("answer_comment")
                if comment_html_field:
                    answers_html += f'<div class="answer-comment editable-html" data-q="{i}" data-a="{j}" data-field="answer_comments_html" style="margin-top:8px;font-size:12px;color:#6a7883;">{_sanitize_html(comment_html_field)}</div>'
                elif comment_raw:
                    answers_html += f'<div class="answer-comment editable-html" data-q="{i}" data-a="{j}" data-field="answer_comments_html" style="margin-top:8px;font-size:12px;color:#6a7883;">{html.escape(comment_raw)}</div>'
        else:
            input_type = "checkbox" if q_type == "multiple_answers_question" else "radio"
            answers_html = f'<ul class="answer-list" data-q="{i}" data-input-type="{input_type}">'
            for j, a in enumerate(answers):
                weight = a.get("answer_weight", 0)
                is_correct = weight == 100
                cls = "correct" if is_correct else "incorrect"
                marker = "&#10003;" if is_correct else chr(ord("A") + j)
                marker_cls = "correct" if is_correct else "incorrect"
                comment_raw = a.get("answer_comment")
                comment_html_field = a.get("answer_comments_html")
                if comment_html_field:
                    comment_html = f'<div class="answer-comment editable-html" data-field="answer_comments_html">{_sanitize_html(comment_html_field)}</div>'
                elif comment_raw:
                    comment_html = f'<div class="answer-comment editable-html" data-field="answer_comments_html">{html.escape(comment_raw)}</div>'
                else:
                    comment_html = f'<div class="answer-comment editable-html empty-feedback" data-field="answer_comments_html"></div>'

                checked_attr = "checked" if is_correct else ""
                input_name = f"q{i}-correct" if input_type == "radio" else f"q{i}-correct-{j}"
                answers_html += f"""
                <li class="answer-item {cls}" data-a="{j}">
                  <input type="{input_type}" class="correct-toggle" name="{input_name}" value="{j}" {checked_attr}>
                  <div class="answer-marker {marker_cls}">{marker}</div>
                  <div class="answer-content">
                    <span class="editable-text" data-field="answer_text">{html.escape(a.get('answer_text', ''))}</span>
                    {comment_html}
                  </div>
                </li>"""
            answers_html += "</ul>"

        # S4: Feedback sections — faculty-authored HTML from Canvas payload.
        # Sanitize in-place (strip <script>/on*=/javascript:) so injected
        # scripts cannot fire; display-only fields stay readable without
        # iframe clipping or null-origin restrictions.
        feedback_html = ""
        if correct_fb:
            feedback_html += f"""
            <div class="feedback-section feedback-correct">
              <div class="feedback-label">Correct Answer Feedback</div>
              {_sanitize_html(correct_fb)}
            </div>"""
        if incorrect_fb:
            feedback_html += f"""
            <div class="feedback-section feedback-incorrect">
              <div class="feedback-label">Incorrect Answer Feedback</div>
              {_sanitize_html(incorrect_fb)}
            </div>"""
        if neutral_fb:
            feedback_html += f"""
            <div class="feedback-section feedback-neutral">
              <div class="feedback-label">General Feedback</div>
              {_sanitize_html(neutral_fb)}
            </div>"""

        # S4: question_text is faculty-authored HTML — sanitize in place so
        # the field remains contenteditable and serializable by the editor JS.
        q_text_safe = _sanitize_html(q_text) if q_text else ""
        parts.append(f"""
        <div class="question-card" data-q="{i}" data-question-type="{html.escape(q_type)}">
          <div class="question-header">
            <h3>Q{i + 1}. {html.escape(q_name)}</h3>
            <div class="question-tags">{tags_html}</div>
          </div>
          <div class="question-body">
            <div class="question-text editable-html" data-field="question_text">{q_text_safe}</div>
            {answers_html}
            {feedback_html}
          </div>
        </div>
        """)

    # Close the Questions tab panel opened above
    parts.append("</div>")  # /#quiz-tab-questions

    # Tab-switching script (vanilla JS, no framework)
    parts.append(_render_quiz_tabs_script())

    if course_id and obj_id:
        parts.append(_quiz_editor_assets())
    content = "\n".join(parts)
    _tok = _get_session_token()
    _tok_meta = (
        f'  <meta name="asucb-staging-token" content="{html.escape(_tok)}">'
        if _tok is not None else ""
    )
    return (
        shell
        .replace("{{CONTENT}}", content)
        .replace("{{TITLE}}", quiz_title)
        .replace("{{TOKEN_META}}", _tok_meta)
    )


def _render_quiz_tabs_bar():
    """Tab bar (Details / Questions). Questions is default unless URL hash is #details."""
    return """
    <div class="quiz-tabs" role="tablist" style="display:flex;gap:0;border-bottom:2px solid #e1e5e8;margin:14px 0 0 0;">
      <button type="button" id="quiz-tab-btn-details" class="quiz-tab-btn" role="tab"
              aria-selected="false" aria-controls="quiz-tab-details"
              data-tab-target="details"
              style="background:transparent;border:0;padding:10px 18px;font-size:14px;font-weight:500;cursor:pointer;color:#6a7883;border-bottom:3px solid transparent;margin-bottom:-2px;">
        Details
      </button>
      <button type="button" id="quiz-tab-btn-questions" class="quiz-tab-btn" role="tab"
              aria-selected="true" aria-controls="quiz-tab-questions"
              data-tab-target="questions"
              style="background:transparent;border:0;padding:10px 18px;font-size:14px;font-weight:500;cursor:pointer;color:#8C1D40;border-bottom:3px solid #8C1D40;margin-bottom:-2px;">
        Questions
      </button>
    </div>
    """


def _render_quiz_details_tab(quiz, course_id="", obj_id=""):
    """Render the Details tab panel with editable form fields.

    Fields (per PR Spec 1):
      title, quiz_type, points_possible (with auto-calc hint + override),
      allowed_attempts, time_limit, scoring_policy, shuffle_answers, published.
    """
    def esc(v):
        return html.escape(str(v)) if v is not None else ""

    title = quiz.get("title", "")
    quiz_type = quiz.get("quiz_type", "practice_quiz")
    points = quiz.get("points_possible", 0)
    allowed = 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)

    qt_opts = [("practice_quiz", "Practice Quiz"), ("assignment", "Assignment (Graded)")]
    qt_html = "".join(
        f'<option value="{v}"{" selected" if quiz_type == v else ""}>{label}</option>'
        for v, label in qt_opts
    )

    scoring_radios = []
    for v, label in [("keep_highest", "Keep highest"), ("keep_latest", "Keep latest"), ("take_average", "Average")]:
        checked = " checked" if scoring == v else ""
        scoring_radios.append(
            f'<label style="margin-right:14px;font-weight:normal;"><input type="radio" name="qd-scoring_policy" '
            f'value="{v}"{checked} data-qd-field="scoring_policy"> {label}</label>'
        )

    data_attrs = (
        f'data-course-id="{html.escape(course_id)}" data-obj-id="{html.escape(obj_id)}"'
        if course_id and obj_id else ""
    )

    return f"""
    <div id="quiz-tab-details" class="quiz-tab-panel" role="tabpanel" aria-labelledby="quiz-tab-btn-details" hidden
         style="padding:16px 4px;max-width:720px;">
      <form id="quiz-details-form" {data_attrs}
            style="display:grid;gap:14px;font-size:14px;">
        <label style="display:grid;gap:4px;font-weight:500;">Title
          <input type="text" name="title" data-qd-field="title" value="{esc(title)}" maxlength="255"
                 style="padding:6px 8px;border:1px solid #cdd5db;border-radius:4px;font-size:14px;">
        </label>
        <label style="display:grid;gap:4px;font-weight:500;">Quiz type
          <select name="quiz_type" data-qd-field="quiz_type"
                  style="padding:6px 8px;border:1px solid #cdd5db;border-radius:4px;font-size:14px;">
            {qt_html}
          </select>
        </label>
        <div style="display:grid;gap:4px;font-weight:500;">Points possible
          <div style="display:flex;align-items:center;gap:10px;">
            <input type="number" name="points_possible" data-qd-field="points_possible"
                   value="{esc(points)}" min="0" step="1" disabled
                   style="padding:6px 8px;border:1px solid #cdd5db;border-radius:4px;font-size:14px;width:120px;">
            <span class="qd-auto-hint" style="font-size:12px;color:#6a7883;">(auto from questions: <span data-qd-auto-points>{esc(points)}</span>)</span>
          </div>
          <label style="font-weight:normal;font-size:13px;margin-top:4px;">
            <input type="checkbox" id="qd-override-points" data-qd-field="_override_points"> override auto-calc
          </label>
        </div>
        <label style="display:grid;gap:4px;font-weight:500;">Allowed attempts <span style="font-weight:normal;font-size:12px;color:#6a7883;">(-1 = Unlimited)</span>
          <input type="number" name="allowed_attempts" data-qd-field="allowed_attempts" value="{esc(allowed)}" step="1"
                 placeholder="Unlimited"
                 style="padding:6px 8px;border:1px solid #cdd5db;border-radius:4px;font-size:14px;width:160px;">
        </label>
        <label style="display:grid;gap:4px;font-weight:500;">Time limit <span style="font-weight:normal;font-size:12px;color:#6a7883;">(minutes; blank = no limit)</span>
          <input type="number" name="time_limit" data-qd-field="time_limit"
                 value="{esc(time_limit) if time_limit is not None else ''}" min="1" step="1"
                 style="padding:6px 8px;border:1px solid #cdd5db;border-radius:4px;font-size:14px;width:160px;">
        </label>
        <fieldset style="border:1px solid #e1e5e8;border-radius:4px;padding:10px 14px;">
          <legend style="font-weight:500;padding:0 4px;">Scoring policy</legend>
          {''.join(scoring_radios)}
        </fieldset>
        <label style="font-weight:normal;">
          <input type="checkbox" name="shuffle_answers" data-qd-field="shuffle_answers"{' checked' if shuffle else ''}>
          Shuffle answers
        </label>
        <label style="font-weight:normal;">
          <input type="checkbox" name="published" data-qd-field="published"{' checked' if published else ''}>
          Published
        </label>
        <div style="display:flex;align-items:center;gap:14px;margin-top:4px;">
          <button type="button" id="quiz-details-save"
                  style="background:#8C1D40;color:#fff;border:0;padding:8px 18px;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;">
            Save details
          </button>
          <span id="quiz-details-status" style="font-size:12px;color:#6a7883;"></span>
        </div>
      </form>
    </div>
    """


def _render_quiz_tabs_script():
    """Vanilla JS for tab switching + URL hash routing + Details save."""
    return r"""
    <script>
    (function() {
      var tabs = {
        details: { btn: document.getElementById('quiz-tab-btn-details'),
                   panel: document.getElementById('quiz-tab-details') },
        questions: { btn: document.getElementById('quiz-tab-btn-questions'),
                     panel: document.getElementById('quiz-tab-questions') }
      };
      if (!tabs.details.btn || !tabs.questions.btn) return;

      function activate(name) {
        Object.keys(tabs).forEach(function(k) {
          var t = tabs[k];
          if (!t.btn || !t.panel) return;
          var active = (k === name);
          t.btn.setAttribute('aria-selected', active ? 'true' : 'false');
          t.btn.style.color = active ? '#8C1D40' : '#6a7883';
          t.btn.style.borderBottom = active ? '3px solid #8C1D40' : '3px solid transparent';
          t.panel.hidden = !active;
        });
      }

      function fromHash() {
        var h = (window.location.hash || '').replace('#', '');
        return (h === 'details') ? 'details' : 'questions';
      }

      tabs.details.btn.addEventListener('click', function() {
        history.replaceState(null, '', '#details');
        activate('details');
      });
      tabs.questions.btn.addEventListener('click', function() {
        history.replaceState(null, '', '#questions');
        activate('questions');
      });
      window.addEventListener('hashchange', function() { activate(fromHash()); });
      activate(fromHash());

      // Details save
      var saveBtn = document.getElementById('quiz-details-save');
      var statusEl = document.getElementById('quiz-details-status');
      var form = document.getElementById('quiz-details-form');
      var overrideCb = document.getElementById('qd-override-points');
      var pointsInput = form ? form.querySelector('[data-qd-field="points_possible"]') : null;
      if (overrideCb && pointsInput) {
        overrideCb.addEventListener('change', function() { pointsInput.disabled = !overrideCb.checked; });
      }
      if (saveBtn && form) {
        saveBtn.addEventListener('click', function() {
          var courseId = form.getAttribute('data-course-id');
          var objId = form.getAttribute('data-obj-id');
          if (!courseId || !objId) return;
          var payload = {
            title: form.querySelector('[data-qd-field="title"]').value,
            quiz_type: form.querySelector('[data-qd-field="quiz_type"]').value,
            allowed_attempts: parseInt(form.querySelector('[data-qd-field="allowed_attempts"]').value, 10),
            scoring_policy: form.querySelector('[name="qd-scoring_policy"]:checked').value,
            shuffle_answers: form.querySelector('[data-qd-field="shuffle_answers"]').checked,
            published: form.querySelector('[data-qd-field="published"]').checked
          };
          var tl = form.querySelector('[data-qd-field="time_limit"]').value;
          if (tl !== '') payload.time_limit = parseInt(tl, 10);
          if (overrideCb && overrideCb.checked) {
            payload.points_possible = parseInt(pointsInput.value, 10);
          }
          statusEl.textContent = 'Saving…';
          var _stagingTok = document.querySelector('meta[name="asucb-staging-token"]')?.content;
          fetch('/api/staging/' + encodeURIComponent(courseId) + '/quizzes/' + encodeURIComponent(objId) + '/details', {
            method: 'PUT',
            headers: Object.assign({'Content-Type': 'application/json'}, _stagingTok ? {'X-ASUCB-Staging-Token': _stagingTok} : {}),
            body: JSON.stringify(payload)
          }).then(function(r) { return r.json().then(function(j) { return { ok: r.ok, body: j }; }); })
            .then(function(res) {
              if (res.ok && res.body && res.body.ok) {
                statusEl.style.color = '#0a7a4a';
                statusEl.textContent = 'Saved';
              } else {
                statusEl.style.color = '#B91C1C';
                statusEl.textContent = (res.body && res.body.error) ? res.body.error : 'Save failed';
              }
            }).catch(function(e) {
              statusEl.style.color = '#B91C1C';
              statusEl.textContent = String(e);
            });
        });
      }
    })();
    </script>
    """


def _render_quiz_settings_panel(quiz):
    """Form panel for Canvas quiz settings. Disabled until edit mode."""
    def esc(v):
        return html.escape(str(v)) if v is not None else ""

    qt = quiz.get("quiz_type", "practice_quiz")
    shuffle = quiz.get("shuffle_answers", True)
    time_limit = quiz.get("time_limit")
    allowed = quiz.get("allowed_attempts", -1)
    scoring = quiz.get("scoring_policy", "keep_highest")
    hide = quiz.get("hide_results")  # null / "until_after_last_attempt" / "always"
    show_correct = quiz.get("show_correct_answers", True)
    one_q = quiz.get("one_question_at_a_time", False)
    ldb = quiz.get("require_lockdown_browser", False)
    ldb_results = quiz.get("require_lockdown_browser_for_results", False)
    ldb_monitor = quiz.get("require_lockdown_browser_monitor", False)
    access = quiz.get("access_code", "")
    ip_filter = quiz.get("ip_filter", "")
    due = quiz.get("due_at", "") or ""
    lock = quiz.get("lock_at", "") or ""
    unlock = quiz.get("unlock_at", "") or ""
    published = quiz.get("published", False)

    qt_options = [("assignment", "Assignment (Graded)"),
                  ("practice_quiz", "Practice Quiz"),
                  ("graded_survey", "Graded Survey"),
                  ("survey", "Survey")]
    score_options = [("keep_highest", "Highest"),
                     ("keep_latest", "Latest"),
                     ("keep_average", "Average")]
    hide_options = [("", "Always show responses"),
                    ("until_after_last_attempt", "Show after last attempt"),
                    ("always", "Never show responses")]

    def opt(options, current):
        return "".join(f'<option value="{esc(v)}"{" selected" if str(current or "") == v else ""}>{esc(label)}</option>' for v, label in options)

    def cb(field, val):
        return f'<input type="checkbox" data-quiz-field="{field}"{" checked" if val else ""} disabled>'

    def dt_local(iso):
        # Canvas uses ISO 8601 UTC (e.g., 2026-05-01T23:59:00Z). datetime-local needs YYYY-MM-DDTHH:MM (no Z).
        if not iso:
            return ""
        return iso.replace("Z", "").split(".")[0][:16]

    return f"""
    <details class="quiz-settings-panel" id="quiz-settings-panel">
      <summary>Quiz settings <span class="settings-hint">(enable edit mode to change)</span></summary>
      <div class="settings-grid">
        <label>Quiz type
          <select data-quiz-field="quiz_type" disabled>{opt(qt_options, qt)}</select>
        </label>
        <label>Scoring policy
          <select data-quiz-field="scoring_policy" disabled>{opt(score_options, scoring)}</select>
        </label>
        <label>Allowed attempts <span class="hint">(-1 = unlimited)</span>
          <input type="number" data-quiz-field="allowed_attempts" value="{esc(allowed)}" disabled>
        </label>
        <label>Time limit <span class="hint">(minutes, blank = none)</span>
          <input type="number" data-quiz-field="time_limit" value="{esc(time_limit) if time_limit is not None else ''}" disabled>
        </label>
        <label>View responses
          <select data-quiz-field="hide_results" disabled>{opt(hide_options, hide)}</select>
        </label>
        <label>Access code
          <input type="text" data-quiz-field="access_code" value="{esc(access)}" disabled>
        </label>
        <label>IP filter
          <input type="text" data-quiz-field="ip_filter" value="{esc(ip_filter)}" disabled>
        </label>
        <label>Unlock at
          <input type="datetime-local" data-quiz-field="unlock_at" value="{esc(dt_local(unlock))}" disabled>
        </label>
        <label>Due at
          <input type="datetime-local" data-quiz-field="due_at" value="{esc(dt_local(due))}" disabled>
        </label>
        <label>Lock at
          <input type="datetime-local" data-quiz-field="lock_at" value="{esc(dt_local(lock))}" disabled>
        </label>
        <label class="checkbox-row">{cb("shuffle_answers", shuffle)} Shuffle answers</label>
        <label class="checkbox-row">{cb("show_correct_answers", show_correct)} Show correct answers</label>
        <label class="checkbox-row">{cb("one_question_at_a_time", one_q)} One question at a time</label>
        <label class="checkbox-row">{cb("require_lockdown_browser", ldb)} Require LockDown Browser</label>
        <label class="checkbox-row">{cb("require_lockdown_browser_for_results", ldb_results)} Require LDB for results</label>
        <label class="checkbox-row">{cb("require_lockdown_browser_monitor", ldb_monitor)} Require LDB Monitor</label>
        <label class="checkbox-row">{cb("published", published)} Published</label>
      </div>
    </details>
    """


def _fetch_canvas_quiz_description(course_id, obj_id):
    """Best-effort live fetch of the current Canvas quiz description.

    Wrapped to swallow any failure (missing CANVAS_TOKEN, network error,
    SystemExit from get_config) so the staging UI never crashes when
    Canvas creds are unavailable.
    """
    if not course_id or not obj_id:
        return ""
    try:
        import canvas_api  # triggers .env load via dotenv
        if not os.environ.get("CANVAS_TOKEN"):
            return ""
        cfg = canvas_api.get_config(course_id=str(course_id))
        live = canvas_api.get_quiz(cfg, obj_id)
        if live and live.get("description"):
            return live["description"]
    except (Exception, SystemExit):
        pass
    return ""


def _render_quiz_description_card(quiz, course_id="", obj_id=""):
    """Quiz description card with mini-RTE toolbar (active in edit mode)."""
    desc = quiz.get("description") or ""
    from_canvas = False
    if not desc:
        live = _fetch_canvas_quiz_description(course_id, obj_id)
        if live:
            desc = live
            from_canvas = True
    canvas_note = (
        '<span class="qd-canvas-note">Loaded from Canvas — save to stage</span>'
        if from_canvas else ""
    )
    return f"""
    <div class="quiz-description-card">
      <div class="qd-header">
        <span class="qd-label">Quiz description</span>
        <span class="qd-hint">Shown above question 1 in Canvas. Edit mode required.</span>
        {canvas_note}
      </div>
      <div class="rte-toolbar" id="rte-toolbar" hidden>
        <button type="button" data-rte-cmd="bold" title="Bold (Ctrl+B)"><b>B</b></button>
        <button type="button" data-rte-cmd="italic" title="Italic (Ctrl+I)"><i>I</i></button>
        <button type="button" data-rte-cmd="underline" title="Underline (Ctrl+U)"><u>U</u></button>
        <span class="toolbar-sep"></span>
        <button type="button" data-rte-cmd="formatBlock" data-rte-arg="H2" title="Heading 2">H2</button>
        <button type="button" data-rte-cmd="formatBlock" data-rte-arg="H3" title="Heading 3">H3</button>
        <button type="button" data-rte-cmd="formatBlock" data-rte-arg="P" title="Paragraph">¶</button>
        <span class="toolbar-sep"></span>
        <button type="button" data-rte-cmd="insertUnorderedList" title="Bulleted list">• List</button>
        <button type="button" data-rte-cmd="insertOrderedList" title="Numbered list">1. List</button>
        <span class="toolbar-sep"></span>
        <button type="button" data-rte-cmd="createLink" title="Insert link">🔗 Link</button>
        <button type="button" data-rte-cmd="unlink" title="Remove link">⌫ Link</button>
        <span class="toolbar-sep"></span>
        <button type="button" data-rte-cmd="removeFormat" title="Clear formatting">⨯ Format</button>
      </div>
      <div class="quiz-description editable-html rte-target"
           data-quiz-field-html="description">{_sanitize_html(desc) if desc else ""}</div>
    </div>
    """


def _quiz_editor_assets():
    """CSS + JS for quiz edit mode. Injected when course_id/obj_id provided."""
    return """
<style>
  .edit-toolbar {
    background: #1f2937; color: #f3f4f6; padding: 10px 16px;
    border-radius: 6px 6px 0 0; display: flex; align-items: center; gap: 16px;
    font-size: 13px; flex-wrap: wrap;
  }
  .back-to-review {
    display: inline-flex; align-items: center; gap: 4px;
    padding: 5px 10px; background: #FFC627; color: #4A0A24;
    text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 12.5px;
    transition: background .15s;
  }
  .back-to-review:hover { background: #ffd454; }
  .back-to-review:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
  .edit-toggle { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
  .save-btn {
    background: #16a34a; color: white; border: none; padding: 6px 14px;
    border-radius: 4px; font-size: 13px; font-weight: 500; cursor: pointer;
  }
  .save-btn:disabled { background: #4b5563; cursor: not-allowed; }
  .save-btn:not(:disabled):hover { background: #15803d; }
  .save-status { color: #9ca3af; font-size: 12px; }
  .dirty-flag { color: #fbbf24; font-size: 12px; }
  .stale-flag { color: #fca5a5; font-size: 12px; font-weight: 500; }

  .edit-mode [contenteditable="true"] {
    outline: 1px dashed #93c5fd; outline-offset: 2px;
    background: #fefce8; cursor: text;
  }
  .edit-mode [contenteditable="true"]:focus {
    outline: 2px solid #2563eb; background: white;
  }
  .edit-mode .empty-feedback::before {
    content: "Click to add feedback"; color: #9ca3af; font-style: italic;
  }
  .edit-mode .empty-feedback:focus::before { content: ""; }
  .correct-toggle { display: none; }
  .edit-mode .correct-toggle { display: inline-block; margin: 4px 6px 0 0; }
  .edit-mode .answer-marker { display: none; }

  .edit-only { display: none; }
  .edit-mode .edit-only { display: inline; }
  .edit-mode .view-only { display: none; }
  .points-input {
    width: 50px; padding: 1px 4px; font-size: 11px; font-weight: 600;
    border: 1px solid #f59e0b; border-radius: 3px; background: white;
    color: #92400e; margin-right: 2px;
  }

  .quiz-settings-panel {
    background: var(--canvas-surface); border: 1px solid var(--canvas-border);
    border-top: none; padding: 0 32px 0 32px; font-size: 13px;
  }
  .quiz-settings-panel summary {
    cursor: pointer; padding: 12px 0; font-weight: 500; color: var(--canvas-text);
    list-style: revert;
  }
  .quiz-settings-panel .settings-hint { color: var(--canvas-text-secondary); font-size: 12px; font-weight: 400; margin-left: 8px; }
  .quiz-settings-panel .settings-grid {
    display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px;
    padding: 4px 0 16px 0;
  }
  .quiz-settings-panel label {
    display: flex; flex-direction: column; gap: 4px;
    font-size: 12px; color: var(--canvas-text-secondary);
  }
  .quiz-settings-panel label.checkbox-row {
    flex-direction: row; align-items: center; gap: 8px; font-size: 13px; color: var(--canvas-text);
  }
  .quiz-settings-panel input[type="text"],
  .quiz-settings-panel input[type="number"],
  .quiz-settings-panel input[type="datetime-local"],
  .quiz-settings-panel select {
    padding: 6px 8px; border: 1px solid var(--canvas-border); border-radius: 4px;
    font-size: 13px; background: white;
  }
  .quiz-settings-panel input:disabled,
  .quiz-settings-panel select:disabled { background: #f3f4f6; color: #6b7280; cursor: not-allowed; }
  .quiz-settings-panel .hint { font-weight: 400; color: var(--canvas-text-secondary); font-size: 11px; }

  .quiz-description-card {
    background: var(--canvas-surface); border: 1px solid var(--canvas-border);
    border-top: none; padding: 16px 32px;
  }
  .qd-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; }
  .qd-label { font-size: 13px; font-weight: 500; color: var(--canvas-text); }
  .qd-hint { font-size: 12px; color: var(--canvas-text-secondary); }
  .qd-canvas-note {
    font-size: 11px; color: #92400e; background: #fef3c7;
    padding: 2px 8px; border-radius: 3px; font-weight: 500;
    margin-left: auto;
  }
  .quiz-description {
    min-height: 60px; padding: 10px 12px;
    border: 1px dashed transparent; border-radius: 4px;
    font-size: 16px; line-height: 1.6; color: var(--canvas-text);
  }
  .quiz-description h1 { font-size: 1.75em; font-weight: 500; margin: 0.5em 0; }
  .quiz-description h2 { font-size: 1.5em; font-weight: 500; margin: 1em 0 0.5em; }
  .quiz-description h3 { font-size: 1.25em; font-weight: 500; margin: 1em 0 0.5em; }
  .quiz-description h4 { font-size: 1.1em; font-weight: 500; margin: 1em 0 0.5em; }
  .quiz-description p { margin: 0 0 1em; }
  .quiz-description a { color: var(--canvas-link); text-decoration: none; }
  .quiz-description a:hover { text-decoration: underline; }
  .quiz-description ul, .quiz-description ol { margin: 0 0 1em 1.5em; }
  .quiz-description li { margin: 0.25em 0; }
  .quiz-description blockquote { border-left: 4px solid var(--canvas-border); margin: 1em 0; padding: 0.5em 1em; color: var(--canvas-text-secondary); }
  .quiz-description table { border-collapse: collapse; width: 100%; margin: 1em 0; }
  .quiz-description th, .quiz-description td { border: 1px solid var(--canvas-border); padding: 8px 12px; text-align: left; }
  .quiz-description th { background: #f5f5f5; 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 .quiz-description th). */
  .quiz-description tr[style*="background"] > th,
  .quiz-description tr[style*="background"] > td { background: transparent; }
  .quiz-description img { max-width: 100%; height: auto; }
  .quiz-description code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
  .quiz-description hr { border: none; border-top: 1px solid var(--canvas-border); margin: 1.5em 0; }
  .quiz-description strong { font-weight: 700; }
  .quiz-description em { font-style: italic; }
  .quiz-description:empty::before { content: "(no description)"; color: #9ca3af; font-style: italic; }
  .edit-mode .quiz-description:empty::before { content: "Click to add description"; }
  .rte-toolbar {
    display: flex; align-items: center; gap: 1px; padding: 4px 6px;
    background: #f2f3f5; border: 1px solid #e4e6e9; border-radius: 4px 4px 0 0;
    margin-bottom: -1px; flex-wrap: wrap;
  }
  .edit-mode .rte-toolbar { display: flex !important; }
  .rte-toolbar[hidden] { display: none; }
  .rte-toolbar button {
    background: none; border: none; cursor: pointer; padding: 5px 8px;
    border-radius: 3px; font-size: 12px; color: #4a5568;
    font-family: inherit; line-height: 1; transition: background .1s;
  }
  .rte-toolbar button:hover { background: #fff; color: var(--canvas-text); }
  .rte-toolbar button.is-active { background: rgba(140,29,64,0.1); color: #8C1D40; }
  .rte-toolbar .toolbar-sep { width: 1px; height: 16px; background: #d4d7dc; margin: 0 3px; }
  .edit-mode .rte-target { border-color: #93c5fd; background: #fefce8; }
  .edit-mode .rte-target:focus { border-color: #2563eb; background: white; outline: none; }
</style>
<script>
(function() {
  const toolbar = document.getElementById('edit-toolbar');
  if (!toolbar) return;
  const courseId = toolbar.dataset.courseId;
  const objId = toolbar.dataset.objId;
  const toggle = document.getElementById('edit-mode-toggle');
  const saveBtn = document.getElementById('save-btn');
  const status = document.getElementById('save-status');
  const dirtyFlag = document.getElementById('dirty-flag');
  const staleFlag = document.getElementById('stale-flag');

  let dirty = false;
  let initialMtime = null;
  let originalJSON = null;

  // Fetch initial JSON snapshot + mtime
  fetch(`/api/staging/${courseId}/quizzes/${objId}/raw`)
    .then(r => r.ok ? r.json() : Promise.reject(r.status))
    .then(d => { originalJSON = d.data; initialMtime = d.mtime;
                 status.textContent = 'Last saved: ' + fmtTime(d.mtime); })
    .catch(e => { status.textContent = 'Load error: ' + e; });

  function fmtTime(ts) {
    if (!ts) return '—';
    return new Date(ts * 1000).toLocaleString();
  }

  function setDirty(v) {
    dirty = v;
    saveBtn.disabled = !v;
    dirtyFlag.hidden = !v;
    document.title = (v ? '● ' : '') + document.title.replace(/^● /, '');
  }

  // Edit-mode toggle
  toggle.addEventListener('change', () => {
    document.body.classList.toggle('edit-mode', toggle.checked);
    document.querySelectorAll('.editable-html, .editable-text, .editable-cell').forEach(el => {
      el.contentEditable = toggle.checked ? 'true' : 'false';
    });
    document.querySelectorAll('[data-quiz-field]').forEach(el => {
      el.disabled = !toggle.checked;
    });
    const tb = document.getElementById('rte-toolbar');
    if (tb) tb.hidden = !toggle.checked;
  });

  // Mini-RTE toolbar (execCommand-based; targets the focused .rte-target)
  // execCommand list commands (insertOrderedList/insertUnorderedList) emit
  // inline styles in Safari; Canvas strips inline styles on import, so list
  // styling may render plain. Acceptable for our Chrome-first workflow.
  let lastRteTarget = null;
  document.addEventListener('focusin', e => {
    if (e.target.classList?.contains('rte-target')) lastRteTarget = e.target;
  });
  document.querySelectorAll('.rte-toolbar button').forEach(btn => {
    btn.addEventListener('mousedown', e => e.preventDefault()); // keep selection
    btn.addEventListener('click', () => {
      if (!lastRteTarget) {
        // TODO: when multiple .rte-target instances exist (e.g. per-question
        // stems), prefer no-op over auto-focusing the first match.
        const first = document.querySelector('.rte-target');
        if (first) { first.focus(); lastRteTarget = first; }
      }
      if (!lastRteTarget) return;
      lastRteTarget.focus();
      const cmd = btn.dataset.rteCmd;
      const arg = btn.dataset.rteArg;
      if (cmd === 'createLink') {
        if (!window.getSelection()?.toString().trim()) {
          alert('Select the link text first, then click Link.');
          return;
        }
        const url = window.prompt('URL', 'https://');
        if (!url) return;
        document.execCommand('createLink', false, url);
      } else if (cmd === 'formatBlock') {
        document.execCommand('formatBlock', false, arg);
      } else {
        document.execCommand(cmd, false, null);
      }
      lastRteTarget.dispatchEvent(new InputEvent('input', {bubbles: true}));
      updateToolbarState();
    });
  });
  function updateToolbarState() {
    const map = [['bold','bold'],['italic','italic'],['underline','underline'],
                 ['insertUnorderedList','insertUnorderedList'],['insertOrderedList','insertOrderedList']];
    map.forEach(([cmd, q]) => {
      const btn = document.querySelector(`.rte-toolbar button[data-rte-cmd="${cmd}"]`);
      if (!btn) return;
      try { btn.classList.toggle('is-active', document.queryCommandState(q)); } catch(_) {}
    });
  }
  document.addEventListener('selectionchange', updateToolbarState);

  // Mark dirty on input
  document.addEventListener('input', e => {
    if (e.target.isContentEditable
        || e.target.classList?.contains('correct-toggle')
        || e.target.dataset?.quizField
        || e.target.dataset?.qField) {
      // Normalize stray <br> in fully-cleared contenteditable so :empty
      // placeholder re-appears (Chrome/Firefox leave a trailing <br>).
      if (e.target.isContentEditable
          && (e.target.innerHTML === '<br>' || e.target.innerHTML === '<br/>')) {
        e.target.innerHTML = '';
      }
      setDirty(true);
    }
  });
  document.addEventListener('change', e => {
    if (e.target.classList?.contains('correct-toggle')
        || e.target.dataset?.quizField
        || e.target.dataset?.qField) {
      setDirty(true);
    }
  });

  // Paste sanitizer: keep only b/i/strong/em + plain text
  document.addEventListener('paste', e => {
    if (!e.target.isContentEditable) return;
    e.preventDefault();
    const html = e.clipboardData.getData('text/html');
    const text = e.clipboardData.getData('text/plain');
    let cleaned;
    if (html) {
      const doc = new DOMParser().parseFromString(html, 'text/html');
      cleaned = sanitizeNode(doc.body).innerHTML;
    } else {
      cleaned = text.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
    }
    document.execCommand('insertHTML', false, cleaned);
  });

  function sanitizeNode(root) {
    const allowed = new Set(['B','STRONG','I','EM','U','BR','P']);
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
    const toUnwrap = [];
    while (walker.nextNode()) {
      const el = walker.currentNode;
      if (!allowed.has(el.tagName)) toUnwrap.push(el);
      else { for (const a of [...el.attributes]) el.removeAttribute(a.name); }
    }
    toUnwrap.forEach(el => {
      while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
      el.remove();
    });
    return root;
  }

  // Build JSON from DOM
  function collect() {
    const data = JSON.parse(JSON.stringify(originalJSON));

    // Quiz settings
    if (!data.quiz || typeof data.quiz !== 'object') data.quiz = {};
    document.querySelectorAll('[data-quiz-field-html]').forEach(el => {
      data.quiz[el.dataset.quizFieldHtml] = el.innerHTML.trim();
    });
    document.querySelectorAll('[data-quiz-field]').forEach(el => {
      const field = el.dataset.quizField;
      let val;
      if (el.type === 'checkbox') val = el.checked;
      else if (el.type === 'number') {
        val = el.value.trim() === '' ? null : Number(el.value);
      } else if (el.type === 'datetime-local') {
        val = el.value ? new Date(el.value).toISOString() : null;
      } else {
        val = el.value === '' ? null : el.value;
      }
      // hide_results: empty = null (always show); preserve null vs absent
      data.quiz[field] = val;
    });

    document.querySelectorAll('.question-card').forEach(card => {
      const qIdx = +card.dataset.q;
      const q = data.questions[qIdx];
      if (!q) return;
      const stem = card.querySelector('[data-field="question_text"]');
      if (stem) q.question_text = stem.innerHTML.trim();
      const pts = card.querySelector('[data-q-field="points_possible"]');
      if (pts && pts.value.trim() !== '') q.points_possible = Number(pts.value);

      const list = card.querySelector('.answer-list');
      if (list) {
        const inputType = list.dataset.inputType;
        const items = list.querySelectorAll('.answer-item');
        items.forEach(li => {
          const aIdx = +li.dataset.a;
          const ans = q.answers[aIdx];
          if (!ans) return;
          const txt = li.querySelector('[data-field="answer_text"]');
          if (txt) ans.answer_text = txt.innerText.trim();
          const fb = li.querySelector('[data-field="answer_comments_html"]');
          if (fb) {
            const inner = fb.innerHTML.trim();
            if (inner) ans.answer_comments_html = inner;
            else delete ans.answer_comments_html;
          }
          const cb = li.querySelector('.correct-toggle');
          if (cb) ans.answer_weight = cb.checked ? 100 : 0;
        });
      }

      const matchTable = card.querySelector('.matching-table');
      if (matchTable) {
        matchTable.querySelectorAll('tr[data-a]').forEach(tr => {
          const aIdx = +tr.dataset.a;
          const ans = q.answers[aIdx];
          if (!ans) return;
          const left = tr.querySelector('[data-field="answer_match_left"]');
          const right = tr.querySelector('[data-field="answer_match_right"]');
          if (left) ans.answer_match_left = left.innerText.trim();
          if (right) ans.answer_match_right = right.innerText.trim();
        });
        // Matching feedback per answer
        card.querySelectorAll('.answer-comment[data-a]').forEach(div => {
          const aIdx = +div.dataset.a;
          const ans = q.answers[aIdx];
          if (!ans) return;
          const inner = div.innerHTML.trim();
          if (inner) ans.answer_comments_html = inner;
          else delete ans.answer_comments_html;
        });
      }
    });
    return data;
  }

  // Save
  saveBtn.addEventListener('click', async () => {
    saveBtn.disabled = true;
    status.textContent = 'Saving…';
    try {
      // Pre-PUT staleness check: refuse silent overwrite
      if (initialMtime) {
        const pre = await fetch(`/api/staging/${courseId}/quizzes/${objId}/meta`).then(x => x.json());
        if (pre.mtime && pre.mtime > initialMtime + 1) {
          staleFlag.hidden = false;
          status.textContent = 'Save blocked: file changed on disk. Reload page.';
          saveBtn.disabled = false;
          return;
        }
      }
      const body = collect();
      const _stagingTok = document.querySelector('meta[name="asucb-staging-token"]')?.content;
      const r = await fetch(`/api/staging/${courseId}/quizzes/${objId}`, {
        method: 'PUT',
        headers: Object.assign({'Content-Type': 'application/json'}, _stagingTok ? {'X-ASUCB-Staging-Token': _stagingTok} : {}),
        body: JSON.stringify(body)
      });
      const j = await r.json();
      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
      originalJSON = body;
      // Refresh mtime
      const m = await fetch(`/api/staging/${courseId}/quizzes/${objId}/meta`).then(x => x.json());
      initialMtime = m.mtime;
      status.textContent = 'Last saved: ' + fmtTime(m.mtime);
      setDirty(false);
      staleFlag.hidden = true;
    } catch (e) {
      status.textContent = 'Save failed: ' + e.message;
      saveBtn.disabled = false;
    }
  });

  // Stale-file detection on focus
  async function checkStale() {
    if (dirty || !initialMtime) return;
    try {
      const r = await fetch(`/api/staging/${courseId}/quizzes/${objId}/meta`);
      const j = await r.json();
      if (j.mtime && j.mtime > initialMtime + 1) staleFlag.hidden = false;
    } catch (_) {}
  }
  window.addEventListener('focus', checkStale);
  setInterval(checkStale, 10000);

  // Warn on close if dirty
  window.addEventListener('beforeunload', e => {
    if (dirty) { e.preventDefault(); e.returnValue = ''; }
  });
})();
</script>
"""


def handle_staging_file_get(handler, course_id, key):
    """Serve bytes for a staged binary file.

    Route: GET /courses/{id}/staging-files/{key}
    Validates course_id and key, serves bytes with Content-Type from sidecar
    mime field (fallback: guess by ext; final fallback application/octet-stream).
    Returns 404 (not 500) on missing file. No directory listing.
    """
    if not COURSE_ID_RE.fullmatch(course_id or ""):
        handler._serve_404("Not found")
        return True
    if not FILE_KEY_RE.fullmatch(key or "") or key in {".", ".."}:
        handler._serve_404("Not found")
        return True

    try:
        course_dir = _resolve_course_dir(course_id)
    except ValueError:
        handler._serve_404("Not found")
        return True

    files_dir = course_dir / "staging" / "files"
    try:
        meta_path = (files_dir / f"{key}.meta.json").resolve(strict=False)
        if not meta_path.is_relative_to(files_dir.resolve(strict=False)):
            handler._serve_404("Not found")
            return True
    except (OSError, RuntimeError, ValueError):
        handler._serve_404("Not found")
        return True

    if not meta_path.exists():
        handler._serve_404("Not found")
        return True

    try:
        meta = json.loads(meta_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        meta = {}

    ext = meta.get("ext", "")
    if not ext:
        handler._serve_404("Not found")
        return True

    try:
        file_path = (files_dir / f"{key}.{ext}").resolve(strict=False)
        if not file_path.is_relative_to(files_dir.resolve(strict=False)):
            handler._serve_404("Not found")
            return True
    except (OSError, RuntimeError, ValueError):
        handler._serve_404("Not found")
        return True

    if not file_path.exists():
        handler._serve_404("Not found")
        return True

    # Determine Content-Type: sidecar mime > guess by ext > fallback.
    # The sidecar mime is data (may be derived from an untrusted source), so it
    # is never trusted as a raw header value: only a well-formed type/subtype
    # passes, otherwise fall back. Blocks CRLF/header injection on this surface.
    mime = meta.get("mime", "")
    if not _SAFE_MIME_RE.fullmatch(mime or ""):
        guessed, _ = mimetypes.guess_type(f"file.{ext}")
        mime = guessed or "application/octet-stream"

    try:
        data = file_path.read_bytes()
    except OSError:
        handler._serve_404("Not found")
        return True

    handler.send_response(200)
    handler.send_header("Content-Type", mime)
    handler.send_header("Content-Length", str(len(data)))
    handler.end_headers()
    handler.wfile.write(data)
    return True


def _load_course_name(course_dir):
    """Load course name from course-config.json, then registry.json fallback."""
    # Try course-config.json first
    config_path = course_dir / "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
    # Try registry.json for Canvas-sourced name
    registry_path = COURSES_DIR.parent / "courses" / "registry.json"
    if not registry_path.exists():
        registry_path = PLUGIN_ROOT / "courses" / "registry.json"
    if registry_path.exists():
        try:
            reg = json.loads(registry_path.read_text(encoding="utf-8"))
            course_id = course_dir.name
            for c in reg.get("courses", []):
                if str(c.get("id", "")) == course_id:
                    return c.get("name", "")
        except (json.JSONDecodeError, OSError):
            pass
    return ""


def _build_index():
    """Build a styled index page listing all courses with staged content."""
    if not COURSES_DIR.exists():
        return _wrap_html("<p>No courses directory found.</p>", "Staging Preview")

    # Collect course data
    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

        course_id = course_dir.name
        course_name = _load_course_name(course_dir) or f"Course {course_id}"

        # Count items by type
        counts = {}
        total = 0
        has_issues = False
        for obj_type, ext in EXTENSIONS.items():
            type_dir = staging_dir / obj_type
            if not type_dir.is_dir():
                continue
            count = 0
            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
                if ".meta." in f.name or ".issues." in f.name or ".vision." in f.name:
                    continue
                count += 1
                # Check for preflight issues
                sidecar = type_dir / (f.name + ".issues.json")
                if sidecar.exists():
                    try:
                        pf = json.loads(sidecar.read_text(encoding="utf-8"))
                        if pf.get("errors", 0) > 0:
                            has_issues = True
                    except (json.JSONDecodeError, OSError):
                        pass
            if count > 0:
                counts[obj_type] = count
                total += count

        has_syllabus = (staging_dir / "syllabus" / "syllabus_body.html").exists()
        if has_syllabus:
            total += 1

        if total > 0:
            courses.append({
                "id": course_id,
                "name": course_name,
                "counts": counts,
                "total": total,
                "has_issues": has_issues,
                "has_syllabus": has_syllabus,
            })

    if not courses:
        return _wrap_html("<p>No staged content found.</p>", "Staging Preview")

    # Build styled HTML
    style = """
    <style>
      .index-container { max-width: 900px; margin: 0 auto; }
      .index-header { margin-bottom: 32px; }
      .index-header h1 { font-size: 24px; font-weight: 600; color: #2d3b45; margin: 0 0 4px; }
      .index-header p { font-size: 14px; color: #6a7883; margin: 0; }
      .course-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
      .course-card {
        background: white; border: 1px solid #e4e4e7; border-radius: 10px;
        padding: 0; overflow: hidden; transition: all 0.15s;
        text-decoration: none; color: inherit; display: block;
      }
      .course-card:hover { border-color: rgba(140,29,64,0.3); box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
      .card-header {
        background: linear-gradient(135deg, #8C1D40 0%, #4A0A24 70%);
        color: white; padding: 20px 24px;
      }
      .card-name { font-size: 18px; font-weight: 500; line-height: 1.3; margin-bottom: 4px; }
      .card-id { font-size: 11px; color: #e0d0d6; font-weight: 400; letter-spacing: 0.5px; }
      .card-body { padding: 16px 24px; }
      .card-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
      .card-stat {
        font-size: 12px; padding: 3px 10px; border-radius: 12px;
        font-weight: 500; display: inline-flex; align-items: center; gap: 4px;
      }
      .stat-pages { background: #e0f2fe; color: #075985; }
      .stat-assignments { background: #fff7ed; color: #9a3412; }
      .stat-discussions { background: #f0fdfa; color: #115e59; }
      .stat-quizzes { background: #fef3c7; color: #92400e; }
      .stat-issues { background: #fef2f2; color: #991b1b; }
      .card-total { font-size: 13px; color: #6a7883; }
      .card-actions { padding: 0 24px 16px; display: flex; gap: 8px; }
      a.btn-review, a.btn-review:hover, a.btn-review:visited {
        background: #8C1D40; color: #ffffff !important; border: none; padding: 8px 20px;
        border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer;
        text-decoration: none !important; display: inline-block; font-family: inherit;
      }
      a.btn-review:hover { background: #6a1530; }
      .btn-items {
        background: white; color: #6a7883; border: 1px solid #e4e4e7; padding: 8px 16px;
        border-radius: 6px; font-size: 12px; cursor: pointer;
        text-decoration: none; display: inline-block; font-family: inherit;
      }
      .btn-items:hover { background: #f5f5f5; color: #2d3b45; }
      .empty-state { text-align: center; padding: 60px 20px; color: #6a7883; }
      .empty-state p { font-size: 14px; }
    </style>
    """

    cards = []
    for c in courses:
        stat_chips = []
        type_labels = {"pages": "Pages", "assignments": "Assignments", "discussions": "Discussions", "quizzes": "Quizzes", "interactives": "Interactives"}
        type_classes = {"pages": "stat-pages", "assignments": "stat-assignments", "discussions": "stat-discussions", "quizzes": "stat-quizzes", "interactives": "stat-quizzes"}
        for t, count in c["counts"].items():
            stat_chips.append(f'<span class="card-stat {type_classes.get(t, "")}">{count} {type_labels.get(t, t)}</span>')
        if c["has_issues"]:
            stat_chips.append('<span class="card-stat stat-issues">Has Issues</span>')

        cards.append(f"""
        <div class="course-card">
          <div class="card-header">
            <div class="card-name">{html.escape(c['name'])}</div>
            <div class="card-id">Course ID: {c['id']}</div>
          </div>
          <div class="card-body">
            <div class="card-stats">{''.join(stat_chips)}</div>
            <div class="card-total">{c['total']} staged items</div>
          </div>
          <div class="card-actions">
            <a href="/courses/{c['id']}/review" class="btn-review">Review All</a>
          </div>
        </div>""")

    content = f"""
    {style}
    <div class="index-container">
      <div class="course-grid">
        {''.join(cards)}
      </div>
    </div>
    """

    # Self-contained page — don't use canvas shell (removes "Canvas Preview — Staged Content" header)
    _tok = _get_session_token()
    _tok_meta = (
        f'  <meta name="asucb-staging-token" content="{html.escape(_tok)}">\n'
        if _tok is not None else ""
    )
    return f'''<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Staging Preview</title>
{_tok_meta}  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
  <style>
    * {{ box-sizing: border-box; margin: 0; padding: 0; }}
    body {{ font-family: 'Roboto', sans-serif; background: #f5f5f5; color: #2d3b45; line-height: 1.6; }}
    .top-bar {{ background: linear-gradient(135deg, #8C1D40 0%, #4A0A24 70%); color: white; padding: 20px 32px; }}
    .top-bar h1 {{ font-size: 22px; font-weight: 500; margin: 0 0 4px; }}
    .top-bar p {{ font-size: 13px; color: #e0d0d6; margin: 0; }}
  </style>
</head>
<body>
  <div class="top-bar">
    <h1>Staging Preview</h1>
    <p>{len(courses)} course{'s' if len(courses) != 1 else ''} with staged content</p>
  </div>
  <div style="max-width:960px;margin:0 auto;padding:32px 24px;">
    {content}
  </div>
</body>
</html>'''


def _send_syllabus_redirect(handler, course_id):
    try:
        _resolve_syllabus_safe(course_id)
    except ValueError:
        handler._serve_404("Not found")
        return
    handler.send_response(302)
    handler.send_header("Location", f"/courses/{course_id}/review#syllabus-body")
    handler.send_header("Content-Length", "0")
    handler.end_headers()


def handle_preview_get(handler, preview_request):
    staging_type = STAGING_TYPES.get(preview_request.obj_type)
    if staging_type is None:
        return False

    if staging_type.name == "syllabus":
        _send_syllabus_redirect(handler, preview_request.course_id)
        return True

    try:
        staged_file = staging_type.resolve(preview_request.course_id, preview_request.key)
    except ValueError:
        handler._serve_404("Not found")
        return True

    if not staged_file.exists():
        handler._serve_404(f"Not staged: {staging_type.name}/{preview_request.key}")
        return True

    try:
        content = staging_type.load(staged_file)
    except json.JSONDecodeError:
        handler._serve_html(_wrap_html(f"<p>Invalid JSON in {staged_file}</p>", "Error"))
        return True

    handler._serve_html(staging_type.wrapper(content, preview_request.course_id, preview_request.key))
    return True


def handle_api_get(handler, api_request):
    staging_type = STAGING_TYPES.get(api_request.obj_type)
    if staging_type is None:
        return False

    try:
        staged_file = staging_type.resolve(api_request.course_id, api_request.key)
    except ValueError:
        handler._json_response(400, {"ok": False, "error": "Invalid path component"})
        return True

    if not staged_file.exists():
        handler._json_response(404, {"ok": False, "error": "Not staged"})
        return True

    if api_request.subroute == "meta":
        handler._json_response(200, {"ok": True, "mtime": int(staged_file.stat().st_mtime)})
        return True

    if api_request.subroute == "raw":
        try:
            data = _raw_data(staging_type, staged_file)
        except (json.JSONDecodeError, OSError) as e:
            handler._json_response(500, {"ok": False, "error": str(e)})
            return True
        handler._json_response(200, {"ok": True, "data": data, "mtime": int(staged_file.stat().st_mtime)})
        return True

    if api_request.subroute == "details" and staging_type.name == "quizzes":
        try:
            quiz_data = _load_json(staged_file)
        except (json.JSONDecodeError, OSError) as e:
            handler._json_response(500, {"ok": False, "error": str(e)})
            return True
        quiz_block = quiz_data.get("quiz", {}) if isinstance(quiz_data, dict) else {}
        handler._json_response(200, {"ok": True, "data": quiz_block, "mtime": int(staged_file.stat().st_mtime)})
        return True

    return False


def _read_json_body(handler):
    content_length = int(handler.headers.get("Content-Length", 0))
    if content_length == 0:
        handler._json_response(400, {"ok": False, "error": "Empty body"})
        return None
    try:
        return json.loads(handler.rfile.read(content_length))
    except json.JSONDecodeError:
        handler._json_response(400, {"ok": False, "error": "Invalid JSON"})
        return None


# Guards the staged-file write critical section against ThreadingHTTPServer
# concurrency (#213). The quiz editor can issue a full-quiz PUT (Save) and a
# details PUT against the same staged quiz file close together; without a
# shared lock, a lock-free read-modify-write in one handler can clobber the
# other's write. A single global lock is sufficient -- staged writes are
# infrequent human-driven saves, so contention is a non-issue.
_STAGED_WRITE_LOCK = threading.Lock()


def handle_api_put(handler, api_request):
    staging_type = STAGING_TYPES.get(api_request.obj_type)
    if staging_type is None:
        return False
    # Allow the "details" subroute on quizzes; reject any other subroute.
    if api_request.subroute and not (
        api_request.subroute == "details" and staging_type.name == "quizzes"
    ):
        return False

    try:
        staged_file = staging_type.resolve(api_request.course_id, api_request.key)
    except ValueError:
        handler._json_response(400, {"ok": False, "error": "Invalid path component"})
        return True

    data = _read_json_body(handler)
    if data is None:
        return True

    if api_request.subroute == "details" and staging_type.name == "quizzes":
        return _handle_quiz_details_put(handler, staged_file, data)

    staged_file.parent.mkdir(parents=True, exist_ok=True)
    try:
        with _STAGED_WRITE_LOCK:
            staging_type.validate_save(data)
            staging_type.write(staged_file, data)
        handler._json_response(200, {"ok": True, "path": str(staged_file)})
    except ValueError as e:
        handler._json_response(400, {"ok": False, "error": str(e)})
    except Exception as e:
        handler._json_response(500, {"ok": False, "error": str(e)})
    return True


def _handle_quiz_details_put(handler, staged_file, payload):
    """Validate via QuizDetailsSchema, merge into staged JSON's quiz block, write back."""
    try:
        from staging_schema import validate_quiz_details
    except ImportError as e:
        handler._json_response(500, {"ok": False, "error": f"staging_schema import failed: {e}"})
        return True

    result = validate_quiz_details(payload)
    if not result.ok:
        first = result.errors[0] if result.errors else None
        msg = first.message if first else "Invalid quiz details payload."
        handler._json_response(
            400,
            {
                "ok": False,
                "error": msg,
                "errors": [
                    {"code": e.code, "message": e.message, "location": e.location}
                    for e in result.errors
                ],
            },
        )
        return True

    if not staged_file.exists():
        handler._json_response(404, {"ok": False, "error": "Not staged"})
        return True
    with _STAGED_WRITE_LOCK:
        try:
            quiz_data = _load_json(staged_file)
        except (json.JSONDecodeError, OSError) as e:
            handler._json_response(500, {"ok": False, "error": str(e)})
            return True
        if not isinstance(quiz_data, dict):
            handler._json_response(500, {"ok": False, "error": "Staged quiz is not a JSON object"})
            return True

        quiz_block = quiz_data.get("quiz", {}) if isinstance(quiz_data.get("quiz"), dict) else {}
        quiz_block.update(payload)
        quiz_data["quiz"] = quiz_block

        try:
            staged_file.parent.mkdir(parents=True, exist_ok=True)
            staged_file.write_text(json.dumps(quiz_data, indent=2, ensure_ascii=False), encoding="utf-8")
        except OSError as e:
            handler._json_response(500, {"ok": False, "error": str(e)})
            return True

    handler._json_response(200, {"ok": True, "data": quiz_block})
    return True


def handle_api_delete(handler, api_request):
    staging_type = STAGING_TYPES.get(api_request.obj_type)
    if staging_type is None or api_request.subroute:
        return False

    try:
        staged_file = staging_type.resolve(api_request.course_id, api_request.key)
    except ValueError:
        handler._json_response(400, {"ok": False, "error": "Invalid path component"})
        return True

    if not staged_file.exists():
        handler._json_response(404, {"ok": False, "error": "Not staged"})
        return True

    try:
        _delete_with_sidecars(staging_type, staged_file, api_request.course_id, api_request.key)
        deleted = "syllabus" if staging_type.name == "syllabus" else f"{staging_type.name}/{api_request.key}"
        handler._json_response(200, {"ok": True, "deleted": deleted})
    except Exception as e:
        handler._json_response(500, {"ok": False, "error": str(e)})
    return True


class StagingHandler(SimpleHTTPRequestHandler):
    """Routes requests to staged content with template rendering."""

    # ALLOWED_ORIGINS is built per-request from the bound port (S2/S3).
    # The class-level set is intentionally left empty; use _allowed_origins().
    ALLOWED_ORIGINS: set[str] = set()

    # ---------------------------------------------------------------------------
    # S2: Host-header validation (DNS-rebinding guard)
    # ---------------------------------------------------------------------------

    def _allowed_origins(self) -> set[str]:
        """Return the set of allowed origins derived from the bound port.

        Uses self.server.server_address[1] so a non-default --port is handled
        correctly without any hardcoded :8111 references.
        """
        port = self.server.server_address[1]
        return {f"http://127.0.0.1:{port}", f"http://localhost:{port}"}

    def _check_host(self) -> bool:
        """Validate the Host header to block DNS-rebinding read attacks (S2).

        Returns True when the request may proceed; sends a 403 and returns
        False when the Host is absent or not in the allowed set.
        """
        port = self.server.server_address[1]
        allowed_hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
        raw_host = (self.headers.get("Host") or "").strip()
        if not raw_host or raw_host not in allowed_hosts:
            self._json_response(403, {"ok": False, "error": "Forbidden host"})
            return False
        return True

    def _check_origin(self) -> bool:
        """Allow write verbs only from the local origin (S3).

        Reads only PUT/POST/DELETE; GET/HEAD/OPTIONS are covered by _check_host.
        """
        if self.command not in {"PUT", "POST", "DELETE"}:
            return True
        origin = self.headers.get("Origin")
        if origin not in self._allowed_origins():
            self._json_response(403, {"ok": False, "error": "Forbidden origin"})
            return False
        return True

    def _check_refresh_origin(self) -> bool:
        """Gate the GET /review?refresh=1 side effect on Origin + token (#215).

        refresh=1 forces generate_review()/fetch_canvas_modules() to make a
        LIVE Canvas API call and write courses/{id}/.module_cache.json,
        bypassing the normal 1h TTL cache. _check_host() covers this route
        like every other GET, but Host is satisfied by construction for any
        request that actually reaches 127.0.0.1 -- it does not prove the
        request came from this app's own UI. Without this gate, any page the
        user's browser visits could force repeated Canvas API calls (token
        rate-limit burn) and create courses/{id}/ dirs for arbitrary
        regex-valid course ids via <img>/<iframe>/fetch, even though the
        response itself is unreadable cross-origin (no ACAO reflected for a
        foreign Origin).

        No browser attaches an Origin header to a plain top-level GET
        navigation (same-origin or not), so a request with no Origin header
        is treated the same as a foreign one here -- the caller falls back
        to normal cache/TTL behavior instead of forcing a live fetch. This
        mirrors the Origin+token gate mutating verbs already get via
        _check_origin()/_check_session_token(), which both skip GET today.
        """
        origin = self.headers.get("Origin")
        if origin not in self._allowed_origins():
            return False
        return self._check_session_token()

    # ---------------------------------------------------------------------------
    # Verb handlers
    # ---------------------------------------------------------------------------

    def do_GET(self):
        if not self._check_host():
            return
        path = unquote(_request_path(self.path))
        parts = _request_parts(self.path)

        # Index
        if path in ("", "/"):
            self._serve_html(_build_index())
            return

        # Route: /courses/{courseId}/review → unified staging review
        if len(parts) == 3 and parts[0] == "courses" and parts[2] == "review":
            course_id = parts[1]
            if not COURSE_ID_RE.fullmatch(course_id or ""):
                self._serve_404("Not found")
                return
            # Honor ?refresh=1 to bypass the Canvas module cache -- but only
            # when _check_refresh_origin() proves the request came from this
            # app's own same-origin UI (#215). A request that fails the gate
            # still renders the review normally; it just cannot force the
            # live Canvas re-fetch + cache write.
            query = self.path.split("?", 1)[1] if "?" in self.path else ""
            refresh_requested = "refresh=1" in query or "refresh=true" in query
            refresh = refresh_requested and self._check_refresh_origin()
            try:
                from unified_staging_review import generate_review
                self._serve_html(generate_review(course_id, refresh=refresh))
            except ImportError:
                self._serve_html(_wrap_html("<p>unified_staging_review.py not found</p>", "Error"))
            except Exception as e:
                self._serve_html(_wrap_html(f"<p>Error: {html.escape(str(e))}</p>", "Error"))
            return

        # Route: /courses/{id}/staging-files/{key} -- serve staged binary bytes
        if len(parts) == 4 and parts[0] == "courses" and parts[2] == "staging-files":
            handle_staging_file_get(self, parts[1], parts[3])
            return

        preview_request = parse_preview_request(parts)
        if preview_request and handle_preview_get(self, preview_request):
            return

        api_request = parse_api_request(parts)
        if api_request and handle_api_get(self, api_request):
            return

        # Bridge GET routes
        if self.do_GET_bridge(path):
            return

        self._serve_404("Not found")

    def do_GET_bridge(self, path):
        """Handle bridge GET routes. (Reserved for future use.)"""
        return False

    def do_HEAD(self):
        """S7: Override do_HEAD to prevent file-existence probing (e.g. HEAD /.env).

        SimpleHTTPRequestHandler.do_HEAD would call send_head(), which serves
        existence and Content-Length for any file under the server root.
        We reject HEAD unconditionally with 405 after the host check.
        """
        if not self._check_host():
            return
        self.send_response(405)
        self.send_header("Allow", "GET, PUT, DELETE, OPTIONS")
        self.end_headers()

    def _check_session_token(self) -> bool:
        """Enforce the synchronizer token on mutating verbs (M3 CSRF remainder).

        Returns True when the request may proceed; sends 403 and returns False
        when the token is set but the request header is absent or incorrect.
        Guard is a no-op when ASUCB_STAGING_TOKEN is unset (test/library use
        without a running server).
        """
        tok = _get_session_token()
        if tok is not None and self.headers.get("X-ASUCB-Staging-Token") != tok:
            self._json_response(
                403,
                {"ok": False, "error": "Missing or invalid session token"},
            )
            return False
        return True

    def do_PUT(self):
        """Save content to staging via PUT, or handle bridge push requests.
        Note: the in-browser Tiptap WYSIWYG autosave was removed (v7.27.0).
        Staging writes now come from staging_manager.py / push scripts, or the
        HTML Source panel's Save button in the unified review."""
        if not self._check_host():
            return
        if not self._check_origin():
            return
        if not self._check_session_token():
            return
        parts = _request_parts(self.path)

        api_request = parse_api_request(parts, allow_subroute=False)
        if api_request and handle_api_put(self, api_request):
            return

        self._json_response(404, {"ok": False, "error": "Not found"})

    def do_DELETE(self):
        """Delete a staged item."""
        if not self._check_host():
            return
        if not self._check_origin():
            return
        if not self._check_session_token():
            return
        parts = _request_parts(self.path)

        api_request = parse_api_request(parts, allow_subroute=False)
        if api_request and handle_api_delete(self, api_request):
            return

        self._json_response(404, {"ok": False, "error": "Not found"})

    def do_OPTIONS(self):
        if not self._check_host():
            return
        self.send_response(200)
        self.end_headers()

    # ---------------------------------------------------------------------------
    # Response helpers
    # ---------------------------------------------------------------------------

    def _serve_html(self, content, code=200):
        self.send_response(code)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(content.encode("utf-8"))

    def _serve_404(self, message):
        self._serve_html(_wrap_html(f"<h2>404</h2><p>{html.escape(message)}</p>", "Not Found"), code=404)

    def _json_response(self, code, data):
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())

    def _cors_headers(self) -> None:
        """S3: Reflect the request Origin only if it is in the allowed set.

        Never emit Access-Control-Allow-Origin: * — that would let any page
        on the machine read staged content from the privileged 127.0.0.1 origin.
        Emitted exactly once per response via the end_headers() override below.
        """
        origin = self.headers.get("Origin") if hasattr(self, "headers") and self.headers else None
        if origin and origin in self._allowed_origins():
            self.send_header("Access-Control-Allow-Origin", origin)
            self.send_header("Vary", "Origin")
        self.send_header("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type, X-ASUCB-Staging-Token")

    def end_headers(self):
        """Single emission point for CORS headers (S3)."""
        self._cors_headers()
        super().end_headers()

    def log_message(self, fmt, *args):
        msg = fmt % args
        if "PUT" in msg or "404" in msg or "500" in msg:
            sys.stderr.write(f"[staging] {msg}\n")


def main():
    parser = argparse.ArgumentParser(description="Staging preview server")
    parser.add_argument("--port", type=int, default=8111, help="Port (default: 8111)")
    args = parser.parse_args()

    # Mint a per-process synchronizer token (M3 CSRF defence-in-depth).
    # Stored in os.environ so both the __main__ module identity and the
    # `staging_server` lazy-import identity (used by unified_staging_review)
    # see the same value — fixing the review-page 403 bug (QB6).
    # Served pages embed it via <meta name="asucb-staging-token">; mutating
    # requests must echo it back in X-ASUCB-Staging-Token.
    os.environ["ASUCB_STAGING_TOKEN"] = secrets.token_urlsafe(32)

    print(f"Staging preview server at http://127.0.0.1:{args.port}/")
    print(f"Serving from: {COURSES_DIR}")
    print(f"Routes:")
    print(f"  /                                    → Index")
    print(f"  /courses/{{id}}/review                → Unified staging review")
    print(f"  /courses/{{id}}/preview/{{type}}/{{key}} → Canonical staged preview")
    print(f"  /courses/{{id}}/preview/syllabus     → Syllabus → unified review (302)")
    print(f"  legacy course preview aliases remain supported")

    # ThreadingHTTPServer: the unified review embeds many staged-image requests
    # (one per module banner) that the single-threaded server served serially,
    # so concurrent iframe image loads timed out (ERR_CONNECTION_TIMED_OUT).
    ThreadingHTTPServer.allow_reuse_address = True
    ThreadingHTTPServer.daemon_threads = True
    try:
        server = ThreadingHTTPServer(("127.0.0.1", args.port), StagingHandler)
    except OSError as e:
        if e.errno in (errno.EADDRINUSE, errno.EADDRNOTAVAIL):
            # Port already bound = the staging server is already running.
            # Reuse it; never fall back to another port (8112, etc.).
            print(
                f"Staging server already running on http://127.0.0.1:{args.port}/ "
                f"(port {args.port} is in use). Reuse that URL. Do NOT start a "
                f"second server or pick another port. Prefer "
                f"scripts/ensure_staging_server.py, which handles this idempotently.",
                file=sys.stderr,
            )
            sys.exit(0)
        raise
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nStopping.")
        server.shutdown()


if __name__ == "__main__":
    main()
