#!/usr/bin/env python3
"""ASUCB Mockup Manager — create, store, and promote faculty-facing course mockups.

Manages mockup pages, placeholder cards, assessment briefs, and self-contained
preview HTML files for faculty presentations.

Storage layout:
    courses/{courseId}/mockup/
        manifest.json           — metadata (course name, version, faculty, status)
        pages/                  — mockup page HTML files
        briefs/                 — structured JSON briefs for non-page content
        feedback.md             — Claude-written faculty feedback log
        {Course Name} Mockup.html      — current self-contained preview
        {Course Name} Mockup-v{N}.html — previous versions
"""

import argparse
import glob
import hashlib
from html import escape as html_escape, unescape as html_unescape
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone

sys.path.insert(0, os.path.dirname(__file__))
from canvas_api import get_plugin_root, validate_course_id
import staging_manager
try:
    import timeline_logger
except Exception:
    timeline_logger = None

VALID_STATUSES = ("pending", "review", "approved", "changes_requested", "stale")

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


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


def _slug_to_label(slug):
    cleaned = re.sub(r'^m\d+-', '', slug)
    # Filename positions may use alpha suffixes (m1-00a-...) to slot dividers without renumbering.
    cleaned = re.sub(r'^\d{1,3}[a-z]?-', '', cleaned)
    cleaned = re.sub(r'-\d+$', '', cleaned)
    return cleaned.replace('-', ' ').title()


def _preview_module_num(slug):
    match = re.match(r'^m(?:odule-?)?(\d+)', slug)
    return int(match.group(1)) if match else 999


def _preview_module_code(mod_num):
    return f"M{mod_num}" if mod_num < 999 else "MX"


def _preview_module_title(mod_num, pages):
    if mod_num >= 999:
        return "Other"
    for page in pages:
        text = page.get("module_title") or page.get("moduleTitle")
        if text:
            return str(text)
    return f"Module {mod_num}"


def _preview_course_code(manifest, course_name, course_id):
    for key in ("courseCode", "course_code", "code", "canvas_code"):
        value = manifest.get(key)
        if value:
            return re.sub(r"\s+", " ", str(value)).strip()
    match = re.search(r'\b([A-Z]{2,5})\s*-?\s*(\d{3}[A-Z]?)\b',
                      str(course_name or "").upper())
    if match:
        return f"{match.group(1)} {match.group(2)}"
    return str(manifest.get("courseId") or course_id)


def _preview_course_title(course_name, code, course_id):
    full_title = re.sub(r"\s+", " ", str(course_name or "")).strip()
    title = full_title
    title = re.sub(
        r"Sandra\s+Day\s+O['\u2019]?Connor\s+College\s+of\s+Law",
        "",
        title,
        flags=re.IGNORECASE,
    )
    title = re.sub(r"\s*(?:--?|[\u2013\u2014])\s*JD\s+Online\.?\s*$", "",
                   title, flags=re.IGNORECASE)
    title = re.sub(r"\bJD\s+Online\.?\s*$", "", title, flags=re.IGNORECASE)
    if code and title.lower().startswith(str(code).lower()):
        remainder = title[len(str(code)):].lstrip(" :-\u2013\u2014")
        if remainder:
            title = remainder
    else:
        match = re.match(
            r"^[A-Z]{2,5}\s*-?\s*\d{3}[A-Z]?\s*[:\-\u2013\u2014]\s*(.+)$",
            title,
            flags=re.IGNORECASE,
        )
        if match:
            title = match.group(1)
    title = re.sub(r"\s+", " ", title).strip(" :-\u2013\u2014|")
    if title.endswith("."):
        title = title[:-1].rstrip()
    return title or full_title or f"Course {course_id}"


def _preview_course_config_name(course_id):
    config_path = os.path.join(
        get_plugin_root(), "courses", str(course_id), "course-config.json")
    try:
        with open(config_path, "r", encoding="utf-8") as f:
            config = json.load(f)
    except (OSError, json.JSONDecodeError, TypeError):
        return ""
    course = config.get("course") if isinstance(config, dict) else {}
    if isinstance(course, dict) and course.get("name"):
        return re.sub(r"\s+", " ", str(course.get("name"))).strip()
    for key in ("courseName", "name", "title"):
        if isinstance(config, dict) and config.get(key):
            return re.sub(r"\s+", " ", str(config.get(key))).strip()
    return ""


def _preview_course_title_source_from_config(config_name):
    match = re.search(
        r"\b[A-Z]{2,5}\s*-?\s*\d{3}[A-Z]?\s*[:\-\u2013\u2014]?\s*",
        config_name,
    )
    return config_name[match.start():] if match else config_name


def _preview_pylon_number(code, course_id):
    match = re.search(r"\d+", str(code or ""))
    if match:
        return match.group(0)
    match = re.search(r"\d+", str(course_id or ""))
    return match.group(0) if match else "000"


def _preview_version_label(version):
    value = str(version or 1)
    return value if value.lower().startswith("v") else f"v{value}"


def _preview_initials(name):
    parts = re.findall(r"[A-Za-z]+", str(name or ""))
    if not parts:
        return "AS"
    return "".join(p[0] for p in parts[:2]).upper()


def _preview_canonical_type(slug, page_type=None, brief_type=None):
    if page_type == "subheader":
        return "subheader"
    if brief_type == "assignment":
        return "assignment"
    if brief_type == "discussion":
        return "discussion"
    if brief_type == "knowledge-check":
        return "quiz"
    if brief_type == "lti_tool":
        return "discussion"
    s = str(slug or "").lower()
    if "overview" in s:
        return "overview"
    if "lecture" in s or "lesson" in s:
        return "lecture"
    if "assignment" in s or "capstone" in s:
        return "assignment"
    if "discussion" in s or "harmonize" in s:
        return "discussion"
    if "quiz" in s or "knowledge-check" in s:
        return "quiz"
    return "page"


def _preview_type_label(slug, page_type=None, brief_type=None):
    if page_type == "subheader":
        return "SubHeader"
    if brief_type == "lti_tool":
        return "External Tool"
    if brief_type == "knowledge-check":
        return "Quiz"
    _, label = _classify_page(slug, page_type)
    if label == "Lesson":
        return "Lecture"
    return label


def _extract_subheader_text(raw_html, filename):
    text = re.sub(r"<[^>]+>", " ", raw_html or "")
    text = html_unescape(re.sub(r"\s+", " ", text).strip())
    if text:
        return text
    return _slug_to_label(filename.replace(".html", ""))


# ============================================================
# Paths
# ============================================================

def _mockup_dir(course_id, subdir=None):
    """Return courses/{courseId}/mockup/ (or a subdir), creating if needed."""
    root = get_plugin_root()
    parts = [root, "courses", str(course_id), "mockup"]
    if subdir:
        parts.append(subdir)
    path = os.path.join(*parts)
    os.makedirs(path, exist_ok=True)
    return path


def _manifest_path(course_id):
    return os.path.join(_mockup_dir(course_id), "manifest.json")


def _sanitize_course_name(name):
    cleaned = re.sub(r'[:/\\?*<>|\x00-\x1f\x7f]', ' ', str(name or ""))
    return re.sub(r'\s+', ' ', cleaned).strip()


def _preview_path(course_id):
    manifest = load_manifest(course_id)
    course_name = _sanitize_course_name(
        manifest.get("courseName") if manifest else "")
    if not course_name:
        course_name = f"Course {course_id}"
    return os.path.join(_mockup_dir(course_id), f"{course_name} Mockup.html")


def _emit_mockup_event(course_id, event_type, payload):
    """Best-effort mockup timeline emission that never interrupts writes."""
    try:
        if timeline_logger is None:
            raise RuntimeError("timeline_logger unavailable")
        timeline_logger.append_event(course_id, event_type, payload)
    except Exception as exc:
        logging.warning("Failed to emit %s for course %s: %s",
                        event_type, course_id, exc)


# ============================================================
# Manifest CRUD
# ============================================================

def create_manifest(course_id, course_name, term="", faculty=""):
    """Create or overwrite manifest.json.

    Returns the manifest dict.
    """
    now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    manifest = {
        "courseId": str(course_id),
        "courseName": course_name,
        "term": term,
        "created": now,
        "updated": now,
        "version": 1,
        "faculty": faculty,
        "status": "pending",
        "last_status_change": now,
        "status_history": [],
    }
    path = _manifest_path(course_id)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(manifest, f, indent=2, ensure_ascii=False)
    return manifest


def load_manifest(course_id):
    """Load manifest.json or return None if it doesn't exist."""
    path = _manifest_path(course_id)
    if not os.path.exists(path):
        return None
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError):
        return None


def update_manifest(course_id, **fields):
    """Partial update to manifest fields. Auto-updates 'updated' timestamp.

    Returns updated manifest or None if not found.
    """
    manifest = load_manifest(course_id)
    if manifest is None:
        return None
    for key, value in fields.items():
        manifest[key] = value
    manifest["updated"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    path = _manifest_path(course_id)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(manifest, f, indent=2, ensure_ascii=False)

    if fields.get("status") == "approved":
        _emit_mockup_event(course_id, "mockup_approved", {
            "status": manifest.get("status", ""),
            "version": manifest.get("version"),
            "updated": manifest.get("updated", ""),
        })
    return manifest


# ============================================================
# Page CRUD
# ============================================================

def save_page(course_id, filename, html, page_type="page", brief_data=None,
              audience=None):
    """Save a mockup page HTML file, with optional structured brief.

    Args:
        course_id: Canvas course ID
        filename: e.g., 'm1-overview.html'
        html: Page HTML content
        page_type: 'page' (real design-system HTML, promotable),
                   'placeholder' (styled card for non-page content), or
                   'subheader' (plain-text Canvas module divider)
        brief_data: Optional dict — when saving a placeholder, pass the
            structured content (quiz questions, discussion prompt, rubric,
            etc.) so it's preserved for later promotion to course-build.
            This is the **only** time the structured data is available
            (Claude has it in context when generating the placeholder).
            Format varies by type:
              knowledge-check: {"questions": [...], "settings": {...}}
              discussion: {"prompt": "...", "type": "...", "grading": {...}}
              assignment: {"description": "...", "rubric": [...], "points": N}
              lti_tool: {
                  "type": "lti_tool",
                  "tool_name": "Harmonize",
                  "module_number": 3,
                  "title": "Module 3 Discussion Forum",
                  "purpose": "Community discussion for module concepts",
                  "configuration_notes": "Teacher-configured forum with...",
                  "integration_requirements": [],
              }
        audience: Optional string from AUDIENCE_LEVELS ('student', 'faculty',
            'ta', 'id'). Stored in .meta.json and used by audience_lint().
            When None, lint defaults the page to 'student'.

    Returns dict with ok, path, page_type, brief_path (if brief saved).
    """
    pages_dir = _mockup_dir(course_id, "pages")
    if not filename.endswith(".html"):
        filename += ".html"
    path = os.path.join(pages_dir, filename)
    subheader_text = None
    if page_type == "subheader":
        subheader_text = _extract_subheader_text(html, filename)
        html = f'<h3 class="module-subheader">{html_escape(subheader_text)}</h3>'
    with open(path, "w", encoding="utf-8") as f:
        f.write(html)

    result = {"ok": True, "path": path, "page_type": page_type}

    # Auto-save brief for placeholder types when structured data is provided
    brief_type = None
    brief_slug = None
    if page_type == "placeholder" and brief_data is not None:
        stem = filename.replace(".html", "")
        module_num, brief_type = _resolve_placeholder_brief(stem, brief_data)
        if brief_type is not None:
            explicit_slug = None
            if not (
                isinstance(brief_data, dict)
                and (brief_data.get("page_slug") or brief_data.get("title"))
            ):
                explicit_slug = stem
            brief_result = save_brief(
                course_id, brief_type, module_num, brief_data, slug=explicit_slug)
            if brief_result.get("ok"):
                result["brief_path"] = brief_result["path"]
                brief_slug = brief_result.get("slug")
                if brief_slug:
                    result["brief_slug"] = brief_slug

    # Store page_type in a sidecar metadata file
    meta = {"page_type": page_type, "filename": filename}
    if brief_type is not None:
        meta["brief_type"] = brief_type
    if brief_slug is not None:
        meta["brief_slug"] = brief_slug
    if audience is not None and audience in AUDIENCE_LEVELS:
        meta["audience"] = audience
    if subheader_text is not None:
        meta["subheader_text"] = subheader_text
    meta_path = path + ".meta.json"
    with open(meta_path, "w", encoding="utf-8") as f:
        json.dump(meta, f, indent=2)

    _emit_mockup_event(course_id, "mockup_updated", {
        "filename": filename,
        "page_type": page_type,
        "path": path,
        "brief_path": result.get("brief_path"),
    })

    return result


def load_page(course_id, filename):
    """Load a mockup page's HTML. Returns string or None."""
    pages_dir = _mockup_dir(course_id, "pages")
    if not filename.endswith(".html"):
        filename += ".html"
    path = os.path.join(pages_dir, filename)
    if not os.path.exists(path):
        return None
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def list_pages(course_id):
    """List all mockup pages with type metadata.

    Returns list of dicts: [{filename, page_type, size_bytes, path}]
    """
    pages_dir = _mockup_dir(course_id, "pages")
    results = []
    for fname in sorted(os.listdir(pages_dir)):
        if not fname.endswith(".html") or fname.endswith(".meta.json"):
            continue
        path = os.path.join(pages_dir, fname)
        meta_path = path + ".meta.json"
        page_type = "page"
        extra_meta = {}
        subheader_text = None
        if os.path.exists(meta_path):
            try:
                with open(meta_path, "r", encoding="utf-8") as f:
                    meta = json.load(f)
                    extra_meta = meta if isinstance(meta, dict) else {}
                    page_type = extra_meta.get("page_type", "page")
                    brief_type = extra_meta.get("brief_type")
                    brief_slug = extra_meta.get("brief_slug")
                    subheader_text = extra_meta.get("subheader_text")
            except (json.JSONDecodeError, OSError):
                brief_type = None
                brief_slug = None
        else:
            brief_type = None
            brief_slug = None
        page_record = {
            "filename": fname,
            "page_type": page_type,
            "size_bytes": os.path.getsize(path),
            "path": path,
        }
        if brief_type:
            page_record["brief_type"] = brief_type
        if brief_slug:
            page_record["brief_slug"] = brief_slug
        if subheader_text:
            page_record["subheader_text"] = subheader_text
        for key in (
            "audience",
            "instructor_note",
            "note",
            "note_author",
            "module_title",
            "moduleTitle",
        ):
            if extra_meta.get(key):
                page_record[key] = extra_meta[key]
        results.append(page_record)
    return results


def rename_page(course_id, old_filename, new_filename):
    """Rename a mockup page. Moves HTML + .meta.json atomically + updates filename in meta.

    Returns dict with ok, old_path, new_path, brief_renamed?

    Notes:
        - Uses a temp suffix to avoid collisions when callers do batch renames
          where target filenames may overlap with other sources.
        - If a brief file is linked via meta.json brief_type/brief_slug, the
          brief is NOT renamed (briefs live at
          briefs/{brief_type}-m{N}-{slug}.json). Caller is responsible if a
          module-number or slug change is implied.
    """
    pages_dir = _mockup_dir(course_id, "pages")
    if not old_filename.endswith(".html"):
        old_filename += ".html"
    if not new_filename.endswith(".html"):
        new_filename += ".html"
    old_p = os.path.join(pages_dir, old_filename)
    new_p = os.path.join(pages_dir, new_filename)
    if not os.path.exists(old_p):
        return {"ok": False, "error": f"Source not found: {old_filename}"}
    if os.path.exists(new_p) and old_p != new_p:
        return {"ok": False, "error": f"Target already exists: {new_filename}. "
                                       f"Use --reorder-module for swaps."}
    os.rename(old_p, new_p)

    # Move meta.json sidecar + update filename inside
    old_meta = old_p + ".meta.json"
    new_meta = new_p + ".meta.json"
    if os.path.exists(old_meta):
        os.rename(old_meta, new_meta)
        try:
            with open(new_meta, "r", encoding="utf-8") as f:
                md = json.load(f)
            md["filename"] = new_filename
            with open(new_meta, "w", encoding="utf-8") as f:
                json.dump(md, f, indent=2)
        except (json.JSONDecodeError, OSError):
            pass

    _emit_mockup_event(course_id, "mockup_updated", {
        "filename": new_filename,
        "renamed_from": old_filename,
        "path": new_p,
    })
    return {"ok": True, "old_path": old_p, "new_path": new_p}


def reorder_module(course_id, module_key, order=None):
    """Renumber all pages in a module (e.g., 'm3', 'm0') with sequential NN prefixes.

    Args:
        course_id: Canvas course ID
        module_key: Module prefix without separator (e.g., 'm0', 'm3', 'tb')
        order: Optional explicit ordered list of canonical slugs (the slug being
            everything after the NN- prefix). When None, pages are sorted by their
            current NN prefix (if present) then by filename.

    Returns dict with ok, renames=[(old, new), ...].

    Uses a 2-pass temp-suffix rename to safely handle reorderings where target
    filenames overlap with source filenames (e.g., shifting positions 02..07 down
    by one).
    """
    pages_dir = _mockup_dir(course_id, "pages")
    prefix = module_key + "-"
    # Collect pages belonging to this module
    items = []
    pattern = re.compile(r"^" + re.escape(prefix) + r"(?:(\d+)-)?(.+?)\.html$")
    for fname in sorted(os.listdir(pages_dir)):
        if not fname.endswith(".html") or fname.endswith(".meta.json"):
            continue
        m = pattern.match(fname)
        if not m:
            continue
        current_nn = int(m.group(1)) if m.group(1) else 999
        slug = m.group(2)
        items.append((current_nn, slug, fname))
    if not items:
        return {"ok": False, "error": f"No pages found for module {module_key}"}

    if order:
        # Reorder per explicit slug list. Unlisted items keep original order at end.
        order_map = {s: i for i, s in enumerate(order)}
        items.sort(key=lambda x: (order_map.get(x[1], 9999), x[0], x[2]))
    else:
        items.sort(key=lambda x: (x[0], x[2]))

    # Build target filenames
    targets = []
    for new_nn, (_, slug, old_fname) in enumerate(items, start=1):
        new_fname = f"{prefix}{new_nn:02d}-{slug}.html"
        targets.append((old_fname, new_fname))

    renames_actually_done = []
    temp_suffix = "._mockup_reorder_tmp"

    # Pass 1: move every file that needs renaming to temp
    for old, new in targets:
        if old == new:
            continue
        old_p = os.path.join(pages_dir, old)
        if os.path.exists(old_p):
            os.rename(old_p, old_p + temp_suffix)
            old_meta = old_p + ".meta.json"
            if os.path.exists(old_meta):
                os.rename(old_meta, old_meta + temp_suffix)

    # Pass 2: temp -> final target
    for old, new in targets:
        if old == new:
            continue
        old_tmp = os.path.join(pages_dir, old + temp_suffix)
        new_p = os.path.join(pages_dir, new)
        if os.path.exists(old_tmp):
            if os.path.exists(new_p):
                os.remove(new_p)
            os.rename(old_tmp, new_p)
            old_meta_tmp = os.path.join(pages_dir, old + ".meta.json" + temp_suffix)
            new_meta = new_p + ".meta.json"
            if os.path.exists(old_meta_tmp):
                if os.path.exists(new_meta):
                    os.remove(new_meta)
                os.rename(old_meta_tmp, new_meta)
                try:
                    with open(new_meta, "r", encoding="utf-8") as f:
                        md = json.load(f)
                    md["filename"] = new
                    with open(new_meta, "w", encoding="utf-8") as f:
                        json.dump(md, f, indent=2)
                except (json.JSONDecodeError, OSError):
                    pass
            renames_actually_done.append({"from": old, "to": new})

    _emit_mockup_event(course_id, "mockup_updated", {
        "operation": "reorder-module",
        "module_key": module_key,
        "renames": renames_actually_done,
    })
    return {"ok": True, "module_key": module_key, "renames": renames_actually_done}


# ============================================================
# Brief CRUD (structured JSON for non-page mockup content)
# ============================================================

BRIEF_TYPES = [
    "knowledge-check",
    "discussion",
    "assignment",
    "lti_tool",
    "activation",        # Private retrieval-practice textbox (online_text_entry, grader-only, completion)
    "capstone-stage",    # One stage of a cross-module artifact; links to parent capstone via brief metadata
    "option-set",        # Pick-1-of-N pathway-tagged Applied Activity options sharing a single rubric
    "final-exam",        # End-of-course exam with mixed MC + short-answer items + topic tagging
]

# ============================================================
# Audience metadata + lint constants (Feature A)
# ============================================================

AUDIENCE_LEVELS = ["student", "faculty", "ta", "id"]

# Forbidden-token patterns per audience level.
# All patterns are case-insensitive. Word-boundary \b anchors are used where
# token is a standalone word (e.g., \bv\d+\b to avoid matching cccv1p2-style
# technical version strings).
#
# "student" inherits all "faculty" tokens plus student-specific ones.
# "id" has no restrictions (internal-only audience).
_FACULTY_FORBIDDEN = [
    # Version markers like v1/v2/v3. Negative lookbehinds skip legitimate
    # cites of published instruments (Michie BCT Taxonomy v1, ICD-10 v1, etc.).
    # The (?<!/) and (?!/) guards skip URL path segments like /api/v1/ —
    # Canvas API endpoints are not version markers.
    r"(?<!Taxonomy )(?<!BCT )(?<!ICD-10 )(?<!CCBC )(?<!/)\bv\d+\b(?!/)",
    r"HPI/ID",
    r"sycophancy",
    r"no sycophancy",
    r"RULES anti-pattern",
    r"counter-position",
    r"rejected from team",
    r"RULES discipline",
    r"severity discipline",
    r"\(internal\)",
    r"\(ID-internal\)",
    r"unpiloted",
]

FORBIDDEN_TOKENS = {
    "faculty": _FACULTY_FORBIDDEN,
    "ta": [
        r"(?<!/)\bv\d+\b(?!/)",
        r"HPI/ID",
        r"sycophancy",
        r"counter-position",
    ],
    "student": _FACULTY_FORBIDDEN + [
        r"\bSLA\b",
        r"\bICC\b",
        r"TA staffing",
        r"pre-term calibration",
        r"rubric calibration",
        r"unpiloted",
        r"pilot data",
        r"team proposal",
        r"cohort distribution rule",
        r"BEM cell",
    ],
    "id": [],  # No restrictions for ID-internal audience
}

# Pre-compiled pattern objects (compiled once at import time)
_COMPILED_PATTERNS = {
    level: [re.compile(p, re.IGNORECASE) for p in patterns]
    for level, patterns in FORBIDDEN_TOKENS.items()
}


def lint_content(html: str, audience: str) -> list[dict]:
    """Return forbidden-token hits for content at the requested audience level."""
    effective_audience = audience if audience in AUDIENCE_LEVELS else "student"
    if effective_audience == "id":
        return []

    patterns = _COMPILED_PATTERNS.get(effective_audience, [])
    if not patterns:
        return []

    content = html or ""
    violations = []
    matched_tokens = []

    for pattern in patterns:
        for m in pattern.finditer(content):
            token = m.group(0)
            if token in matched_tokens:
                continue
            matched_tokens.append(token)
            start = max(0, m.start() - 30)
            end = min(len(content), m.end() + 30)
            snippet = content[start:end].replace("\n", " ").strip()
            violations.append({
                "token": token,
                "line_hint": f"...{snippet}...",
            })

    return violations


def audience_lint(course_id, override_audience=None):
    """Scan all mockup pages for audience-forbidden token violations.

    For each page, determines the effective audience:
      1. If override_audience is set, uses that for ALL pages (batch-sweep mode).
      2. Otherwise reads the page's .meta.json audience field.
      3. Falls back to 'student' when meta has no audience (safe default — most
         pages are student-facing and the student list is the strictest).

    Args:
        course_id: Canvas course ID
        override_audience: Optional string from AUDIENCE_LEVELS. When set,
            ALL pages are linted against this audience's forbidden list.

    Returns:
        {
            "ok": True,
            "course_id": str(course_id),
            "pages_scanned": N,
            "violations": [
                {
                    "filename": "00-redesign-rationale.html",
                    "audience": "faculty",
                    "tokens": ["v3", "HPI/ID"],
                    "line_hints": ["...around 'Redesign Proposal v3'...", "..."]
                },
                ...
            ]
        }
        ok is True even when violations are found — callers use len(violations) > 0
        to decide strictness; --strict mode in CLI exits non-zero on violations.
    """
    if override_audience is not None and override_audience not in AUDIENCE_LEVELS:
        return {
            "ok": False,
            "error": f"Invalid audience level: {override_audience!r}. "
                     f"Must be one of: {AUDIENCE_LEVELS}",
        }

    pages = list_pages(course_id)
    violations = []

    for page in pages:
        filename = page["filename"]
        pages_dir = _mockup_dir(course_id, "pages")
        page_path = os.path.join(pages_dir, filename)
        meta_path = page_path + ".meta.json"

        # Determine effective audience
        if override_audience is not None:
            effective_audience = override_audience
        else:
            effective_audience = "student"  # safe default
            if os.path.exists(meta_path):
                try:
                    with open(meta_path, "r", encoding="utf-8") as f:
                        meta = json.load(f)
                    effective_audience = meta.get("audience", "student")
                    if effective_audience not in AUDIENCE_LEVELS:
                        effective_audience = "student"
                except (json.JSONDecodeError, OSError):
                    pass

        # Read page content
        try:
            with open(page_path, "r", encoding="utf-8") as f:
                content = f.read()
        except OSError:
            continue

        content_violations = lint_content(content, effective_audience)
        if content_violations:
            violations.append({
                "filename": filename,
                "audience": effective_audience,
                "tokens": [v["token"] for v in content_violations],
                "line_hints": [v["line_hint"] for v in content_violations],
            })

    return {
        "ok": True,
        "course_id": str(course_id),
        "pages_scanned": len(pages),
        "violations": violations,
    }


def _resolve_placeholder_brief(stem, brief_data=None, explicit_brief_type=None):
    """Infer the brief type and module number for a placeholder filename."""
    module_match = re.match(r"^m(\d+)-", stem)
    if not module_match:
        return None, None

    brief_type = explicit_brief_type
    if brief_type is None and isinstance(brief_data, dict):
        brief_type = brief_data.get("type")
    if brief_type in BRIEF_TYPES:
        return int(module_match.group(1)), brief_type

    # Legacy filename suffix inference (e.g., m1-knowledge-check, m1-04-applied-activity).
    # Allows an optional position prefix between module and type, e.g., m{N}-{NN}-{type}.
    legacy_pattern = (
        r"^m(\d+)(?:-\d+)?-("
        r"knowledge-check|discussion|assignment|"
        r"activation|capstone-stage\d*|applied-activity|option-set|"
        r"log-prompt|final-exam"
        r")"
    )
    legacy_match = re.match(legacy_pattern, stem)
    if legacy_match:
        suffix = legacy_match.group(2)
        # Normalize specific suffixes to canonical brief types
        if suffix.startswith("capstone-stage"):
            mapped = "capstone-stage"
        elif suffix == "applied-activity":
            mapped = "option-set"  # AA = pathway-tagged pick-1-of-N
        elif suffix == "log-prompt":
            mapped = "assignment"  # Log entries are graded text-entry assignments
        else:
            mapped = suffix
        if mapped in BRIEF_TYPES:
            return int(legacy_match.group(1)), mapped

    return None, None


def _content_hash_slug(content):
    """Return the deterministic brief slug used when no title/page slug exists."""
    return hashlib.sha1(
        json.dumps(content, sort_keys=True).encode("utf-8")
    ).hexdigest()[:8]


def _slugify_brief_source(src):
    return re.sub('-+', '-', re.sub('[^a-z0-9]', '-', str(src).lower())).strip('-')[:40]


def _derive_brief_slug(content, slug=None):
    if slug is not None:
        derived = _slugify_brief_source(slug)
        return derived or _content_hash_slug(content)

    src = None
    if isinstance(content, dict):
        src = content.get("page_slug") or content.get("title")
    if src:
        derived = _slugify_brief_source(src)
        if derived:
            return derived
    return _content_hash_slug(content)


def _brief_filename(brief_type, module_num, slug=None):
    if slug is None:
        return f"{brief_type}-m{module_num}.json"
    return f"{brief_type}-m{module_num}-{slug}.json"


def _read_brief_file(path):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError):
        return None


def save_brief(course_id, brief_type, module_num, content, slug=None):
    """Save a structured JSON brief for non-page content.

    Args:
        course_id: Canvas course ID
        brief_type: One of BRIEF_TYPES
        module_num: Module number
        content: Dict with structured content
        slug: Optional filename discriminator. Defaults to content page_slug,
            then title, then a stable content hash.

    Returns dict with ok, path, slug.
    """
    if brief_type not in BRIEF_TYPES:
        return {"ok": False, "error": f"Invalid brief type: {brief_type}. "
                f"Must be one of: {BRIEF_TYPES}"}
    briefs_dir = _mockup_dir(course_id, "briefs")
    brief_slug = _derive_brief_slug(content, slug=slug)
    filename = _brief_filename(brief_type, module_num, brief_slug)
    path = os.path.join(briefs_dir, filename)
    if os.path.exists(path):
        existing = _read_brief_file(path)
        if existing != content:
            return {
                "ok": False,
                "error": "Brief collision: path already exists with different "
                         "content. Pass a distinct slug.",
            }
        return {"ok": True, "path": str(path), "slug": brief_slug}

    with open(path, "w", encoding="utf-8") as f:
        json.dump(content, f, indent=2, ensure_ascii=False)
    return {"ok": True, "path": str(path), "slug": brief_slug}


def load_brief(course_id, brief_type, module_num, slug=None):
    """Load a brief JSON file. Returns dict or None."""
    briefs_dir = _mockup_dir(course_id, "briefs")
    if slug is not None:
        filename = _brief_filename(brief_type, module_num, _slugify_brief_source(slug))
        return _read_brief_file(os.path.join(briefs_dir, filename))

    pattern = os.path.join(briefs_dir, _brief_filename(brief_type, module_num, "*"))
    matches = sorted(glob.glob(pattern))
    if len(matches) == 1:
        return _read_brief_file(matches[0])
    if len(matches) > 1:
        logging.warning(
            "Multiple briefs match %s module %s; pass slug to load one.",
            brief_type,
            module_num,
        )
        return None

    old_path = os.path.join(briefs_dir, _brief_filename(brief_type, module_num))
    if not os.path.exists(old_path):
        return None
    return _read_brief_file(old_path)


def list_briefs(course_id):
    """List all available briefs for a course mockup.

    Returns list of dicts: [{brief_type, module_num, slug, filename, path}]
    """
    briefs_dir = _mockup_dir(course_id, "briefs")
    if not os.path.isdir(briefs_dir):
        return []
    results = []
    type_group = "|".join(re.escape(t) for t in BRIEF_TYPES)
    new_pattern = re.compile(rf"^({type_group})-m(\d+)-(.*)\.json$")
    old_pattern = re.compile(rf"^({type_group})-m(\d+)\.json$")
    for fname in sorted(os.listdir(briefs_dir)):
        match = new_pattern.match(fname)
        if match:
            results.append({
                "brief_type": match.group(1),
                "module_num": int(match.group(2)),
                "slug": match.group(3),
                "filename": fname,
                "path": os.path.join(briefs_dir, fname),
            })
            continue
        match = old_pattern.match(fname)
        if match:
            results.append({
                "brief_type": match.group(1),
                "module_num": int(match.group(2)),
                "slug": "",
                "filename": fname,
                "path": os.path.join(briefs_dir, fname),
            })
    return results


# ============================================================
# Preview generation
# ============================================================

def generate_preview(course_id):
    """Stitch all mockup pages into a single preview HTML with navigation.

    - Reads preview-base.html template
    - Inserts all pages as sections with scroll targets
    - Builds sidebar nav from page list
    - Versions previous preview as {Course Name} Mockup-v{N}.html

    Returns dict with ok, path, version.
    """
    manifest = load_manifest(course_id)
    if manifest is None:
        return {"ok": False, "error": "No manifest found. Create a mockup first."}

    pages = list_pages(course_id)
    if not pages:
        return {"ok": False, "error": "No mockup pages found."}

    # Version the previous preview
    preview = _preview_path(course_id)
    version = manifest.get("version", 1)
    if os.path.exists(preview):
        base, ext = os.path.splitext(preview)
        versioned = f"{base}-v{version}{ext}"
        os.rename(preview, versioned)
        version += 1
        update_manifest(course_id, version=version)

    # Load template
    root = get_plugin_root()
    template_path = os.path.join(
        root, "templates", "mockup-html", "preview-base.html")
    if os.path.exists(template_path):
        with open(template_path, "r", encoding="utf-8") as f:
            template = f.read()
    else:
        # Fallback: generate a basic template inline
        template = _fallback_template()

    # Group pages by module
    modules = {}
    for page in pages:
        page_id = page["filename"].replace(".html", "")
        mod_num = _preview_module_num(page_id)
        if mod_num not in modules:
            modules[mod_num] = []
        modules[mod_num].append(page)

    nav_items = []
    content_sections = []
    module_progress_css = []
    page_num = 0
    total_pages = len([p for p in pages if p["page_type"] != "subheader"])
    first_page_id = None
    assignment_count = 0

    for module_index, mod_num in enumerate(sorted(modules.keys())):
        mod_pages = modules[mod_num]
        mod_code = _preview_module_code(mod_num)
        mod_title = _preview_module_title(mod_num, mod_pages)
        collapsible_id = f"toc-{mod_code.lower()}-pages"
        collapsed = module_index > 1
        module_active = False
        ready_count = 0
        draft_count = 0
        empty_count = 0
        counted_items = 0

        preview_rows = []
        for page in mod_pages:
            page_id = page["filename"].replace(".html", "")
            html = load_page(course_id, page["filename"]) or ""
            brief = None
            if page["page_type"] == "placeholder":
                module_num, brief_type = _resolve_placeholder_brief(
                    page_id, explicit_brief_type=page.get("brief_type"))
                if brief_type is not None:
                    brief = load_brief(
                        course_id, brief_type, module_num,
                        slug=page.get("brief_slug"))
            canonical_type = _preview_canonical_type(
                page_id, page["page_type"], page.get("brief_type"))
            type_label = _preview_type_label(
                page_id, page["page_type"], page.get("brief_type"))
            label = page.get("subheader_text") or _slug_to_label(page_id)
            if isinstance(brief, dict):
                label = brief.get("title") or brief.get("page_title") or label
            status = "ready"
            if page["page_type"] == "placeholder":
                status = "draft" if brief else "placeholder"
            elif page["page_type"] == "subheader":
                status = "subheader"
            if status != "subheader":
                counted_items += 1
                if status == "ready":
                    ready_count += 1
                elif status == "draft":
                    draft_count += 1
                else:
                    empty_count += 1
                if canonical_type == "assignment":
                    assignment_count += 1
            preview_rows.append({
                "page": page,
                "page_id": page_id,
                "html": html,
                "brief": brief,
                "canonical_type": canonical_type,
                "type_label": type_label,
                "label": label,
                "status": status,
            })

        if first_page_id is None:
            for row in preview_rows:
                if row["status"] != "subheader":
                    first_page_id = row["page_id"]
                    module_active = True
                    break
        else:
            module_active = any(row["page_id"] == first_page_id for row in preview_rows)

        ready_pct = int(round((ready_count / counted_items) * 100)) if counted_items else 0
        module_progress_css.append(
            f'.module-prelude[data-module="{mod_code}"] '
            f'.module-progress-fill {{ width:{ready_pct}%; }}'
        )

        content_sections.append(
            f'<section class="module-prelude{" active" if module_active else ""}" '
            f'id="module-{mod_code.lower()}" data-module="{mod_code}">\n'
            f'  <div class="module-prelude-top">\n'
            f'    <div class="module-code">{html_escape(mod_code)}</div>\n'
            f'    <h2 class="module-title">{html_escape(mod_title)}</h2>\n'
            f'    <div class="module-counts">'
            f'<span><strong class="ready">{ready_count}</strong> ready</span>'
            f'<span><strong class="draft">{draft_count}</strong> draft</span>'
            f'<span><strong class="empty">{empty_count}</strong> empty</span>'
            f'</div>\n'
            f'  </div>\n'
            f'  <div class="module-rule"><span class="module-progress-fill"></span></div>\n'
            f'</section>'
        )

        toc_pages = []
        module_page_num = 0
        for row in preview_rows:
            page_id = row["page_id"]
            label = row["label"]
            if row["status"] == "subheader":
                search = " ".join([mod_code, mod_title, label, page_id])
                toc_pages.append(
                    f'            <li class="toc-subheader" title="{html_escape(page_id)}" '
                    f'data-search="{html_escape(search, quote=True)}">'
                    f'<span>{html_escape(label)}</span></li>'
                )
                continue
            module_page_num += 1
            is_active_page = first_page_id == page_id
            placeholder_cls = " placeholder" if row["status"] == "placeholder" else ""
            active_cls = " active" if is_active_page else ""
            current_attr = ' aria-current="page"' if is_active_page else ""
            search = " ".join([
                mod_code, mod_title, label, page_id, row["type_label"],
                row["canonical_type"], row["status"],
                row["page"].get("audience", ""),
            ])
            toc_pages.append(
                f'            <li><a class="toc-page{placeholder_cls}{active_cls}" '
                f'href="#{html_escape(page_id)}" title="{html_escape(page_id)}" '
                f'data-search="{html_escape(search, quote=True)}"'
                f'{current_attr}>'
                f'<span class="toc-index mono">{module_page_num:02d}</span>'
                f'<span class="toc-type-dot type-{html_escape(row["canonical_type"])}"></span>'
                f'<span class="toc-page-title">{html_escape(label)}</span></a></li>'
            )

        nav_items.append(
            f'        <div class="toc-module{" active" if module_active else ""}'
            f'{" collapsed" if collapsed else ""}" data-module="{html_escape(mod_code)}">\n'
            f'          <button class="toc-module-header" type="button" '
            f'aria-expanded="{str(not collapsed).lower()}" '
            f'aria-controls="{collapsible_id}">\n'
            f'            <span class="toc-mod-code">{html_escape(mod_code)}</span>\n'
            f'            <span class="toc-mod-title">{html_escape(mod_title)}</span>\n'
            f'            <span class="toc-arrow" aria-hidden="true">&#9662;</span>\n'
            f'          </button>\n'
            f'          <ol class="toc-pages" id="{collapsible_id}">\n'
            f'{chr(10).join(toc_pages)}\n'
            f'          </ol>\n'
            f'        </div>'
        )

        # Build content sections
        for row in preview_rows:
            page = row["page"]
            page_id = row["page_id"]
            label = row["label"]
            html = row["html"]
            if row["status"] == "subheader":
                content_sections.append(
                    f'<div id="{html_escape(page_id)}" class="module-subheader-divider">'
                    f'<span>{html_escape(label)}</span></div>'
                )
                continue
            page_num += 1
            status_label = (
                "Ready" if row["status"] == "ready"
                else "Draft" if row["status"] == "draft"
                else "Placeholder"
            )
            body_html = html
            if not body_html.strip():
                body_html = (
                    '<div class="placeholder-empty">'
                    '<strong>Placeholder</strong>'
                    '<span>Faculty has not authored this page yet.</span>'
                    '</div>'
                )
            note = page.get("instructor_note") or page.get("note")
            note_author = page.get("note_author") or manifest.get("faculty") or "ASU"
            note_html = ""
            if note:
                note_html = (
                    f'<aside class="instructor-note">\n'
                    f'  <div class="note-title">Instructor note</div>\n'
                    f'  <div class="note-text">{html_escape(str(note))}</div>\n'
                    f'  <div class="note-author"><span class="note-avatar">'
                    f'{html_escape(_preview_initials(note_author))}</span>'
                    f'{html_escape(str(note_author))}</div>\n'
                    f'</aside>'
                )
            grid_class = "page-grid has-note" if note else "page-grid"
            content_sections.append(
                f'<article id="{html_escape(page_id)}" '
                f'class="page-section status-{row["status"]}">\n'
                f'  <header class="page-header">\n'
                f'    <span class="type-pill">{html_escape(mod_code)} &middot; '
                f'{html_escape(row["type_label"])}</span>\n'
                f'    <span class="page-id mono">{html_escape(page_id)}</span>\n'
                f'    <h3 class="page-title">{html_escape(label)}</h3>\n'
                f'    <span class="page-header-spacer"></span>\n'
                f'    <span class="status-pill {row["status"]}">'
                f'<span class="status-dot"></span>{status_label}</span>\n'
                f'    <button class="edit-btn" type="button">Edit</button>\n'
                f'  </header>\n'
                f'  <div class="{grid_class}">\n'
                f'    <div class="page-content">{body_html}</div>\n'
                f'    {note_html}\n'
                f'  </div>\n'
                f'</article>'
            )

    # Substitute into template
    course_name = manifest.get("courseName") or f"Course {course_id}"
    course_code = _preview_course_code(manifest, course_name, course_id)
    course_title = _preview_course_title(course_name, course_code, course_id)
    if course_title.lower() == str(course_code).lower():
        config_course_name = _preview_course_config_name(course_id)
        if config_course_name:
            config_title = _preview_course_title(
                _preview_course_title_source_from_config(config_course_name),
                course_code,
                course_id,
            )
            if config_title.lower() != course_title.lower():
                course_title = config_title
    term = manifest.get("term") or "Term TBD"
    faculty = manifest.get("faculty") or "TBD"
    updated = manifest.get("updated") or datetime.now(timezone.utc).strftime("%Y-%m-%d")
    version_label = _preview_version_label(version)
    module_count = len(modules)

    output = template
    output = output.replace("{{COURSE_NAME}}", html_escape(course_name))
    output = output.replace("{{COURSE_TITLE}}", html_escape(course_title))
    output = output.replace("{{COURSE_CODE}}", html_escape(course_code))
    output = output.replace("{{PYLON_NUMBER}}", html_escape(_preview_pylon_number(course_code, course_id)))
    output = output.replace("{{TERM}}", html_escape(str(term)))
    output = output.replace("{{FACULTY}}", html_escape(str(faculty)))
    output = output.replace("{{VERSION}}", html_escape(version_label))
    output = output.replace("{{UPDATED}}", html_escape(str(updated)))
    output = output.replace("{{DATE}}", datetime.now(timezone.utc).strftime("%Y-%m-%d"))
    output = output.replace("{{NAV_ITEMS}}", "\n    ".join(nav_items))
    output = output.replace("{{CONTENT_SECTIONS}}", "\n\n".join(content_sections))
    output = output.replace("{{PAGE_COUNT}}", str(total_pages))
    output = output.replace("{{MODULE_COUNT}}", str(module_count))
    output = output.replace("{{ASSIGNMENT_COUNT}}", str(assignment_count))
    output = output.replace("{{MODULE_PROGRESS_CSS}}", "\n  ".join(module_progress_css))

    with open(preview, "w", encoding="utf-8") as f:
        f.write(output)

    # Audience-lint warning (non-blocking): run after writing preview so the
    # preview is always produced regardless of violations.
    lint_result = audience_lint(course_id)
    violation_count = len(lint_result.get("violations", []))
    if violation_count > 0:
        print(
            f"WARNING: {violation_count} page(s) contain audience-forbidden tokens. "
            "Run --audience-lint for details.",
            file=sys.stderr,
        )

    return {"ok": True, "path": preview, "version": version,
            "page_count": total_pages}


def _fallback_template():
    """Minimal preview template if preview-base.html doesn't exist yet."""
    return """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{COURSE_NAME}} — Course Mockup</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #f7f7f8; color: #18181b; }
.layout { display: flex; min-height: 100vh; }
.sidebar { width: 260px; background: #1B1B2F; color: white; padding: 24px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; }
.sidebar-header { padding: 0 20px 20px; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 12px; }
.sidebar-header h2 { font-size: 16px; color: #FFC627; margin-bottom: 4px; }
.sidebar-header p { font-size: 12px; color: #aaa; }
.nav-item { display: block; padding: 10px 20px; color: #ccc; text-decoration: none; font-size: 13px; border-left: 3px solid transparent; }
.nav-item:hover { background: rgba(255,255,255,0.05); color: white; }
.nav-item.placeholder { color: #8C1D40; font-style: italic; }
.toc-subheader { padding: 7px 20px; color: #94a3b8; font-size: 12px; font-style: italic; text-align: center; }
.module-subheader-divider { display: flex; align-items: center; gap: 12px; justify-content: center; color: #64748b; font-size: 14px; font-style: italic; margin: 6px 0 20px; }
.module-subheader-divider::before, .module-subheader-divider::after { content: ""; height: 1px; background: #cbd5e1; flex: 1; max-width: 180px; }
.main { margin-left: 260px; padding: 32px; flex: 1; }
.page-section { background: white; border: 1px solid #e4e4e7; border-radius: 8px; margin-bottom: 24px; overflow: hidden; }
.section-header { padding: 16px 24px; background: linear-gradient(135deg, #8C1D40, #2D0618); color: white; font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 12px; }
.type-badge { font-size: 10px; background: rgba(255,198,39,0.2); color: #FFC627; padding: 2px 8px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.page-content { padding: 24px; }
.footer { text-align: center; padding: 32px; color: #71717a; font-size: 12px; }
@media print { .sidebar { display: none; } .main { margin-left: 0; } }
</style>
</head>
<body>
<div class="layout">
  <nav class="sidebar">
    <div class="sidebar-header">
      <h2>{{COURSE_NAME}}</h2>
      <p>{{META_LINE}}</p>
      <p style="margin-top:4px;">v{{VERSION}} | {{PAGE_COUNT}} pages</p>
    </div>
    {{NAV_ITEMS}}
  </nav>
  <main class="main">
    {{CONTENT_SECTIONS}}
    <div class="footer">
      Generated by ASU Canvas Builder | v{{VERSION}} | {{DATE}}
    </div>
  </main>
</div>
<script>
// Scroll-spy: highlight active nav item
const sections = document.querySelectorAll('.page-section');
const navItems = document.querySelectorAll('.nav-item');
const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      navItems.forEach(n => n.style.borderLeftColor = 'transparent');
      navItems.forEach(n => n.style.color = '#ccc');
      const active = document.querySelector('.nav-item[href="#' + entry.target.id + '"]');
      if (active) { active.style.borderLeftColor = '#FFC627'; active.style.color = 'white'; }
    }
  });
}, { threshold: 0.3 });
sections.forEach(s => observer.observe(s));
</script>
</body>
</html>"""


# ============================================================
# Promote to staging
# ============================================================

def promote_page(course_id, filename):
    """Copy a mockup page's HTML to the same course's staging area.

    Only promotes 'page' type files, not 'placeholder' or 'subheader' types.
    Returns dict with ok, staged_path, or error.
    """
    pages = list_pages(course_id)
    target = None
    for p in pages:
        if p["filename"] == filename or p["filename"] == filename + ".html":
            target = p
            break

    if target is None:
        return {"ok": False, "error": f"Page '{filename}' not found in mockup."}

    if target["page_type"] == "subheader":
        return {"ok": True, "skipped": True, "reason": "subheader"}

    if target["page_type"] == "placeholder":
        return {"ok": False, "error": f"'{filename}' is a placeholder card "
                "(non-page content). Use promote_all to extract briefs, "
                "or promote page-type files only."}

    html = load_page(course_id, target["filename"])
    if html is None:
        return {"ok": False, "error": f"Could not read page '{filename}'."}

    # Derive slug from filename (strip .html)
    slug = target["filename"].replace(".html", "")
    result = staging_manager.stage(course_id, "pages", slug, html)

    return {
        "ok": True,
        "staged_path": result.get("path", ""),
        "slug": slug,
    }


def promote_all(course_id):
    """Promote all mockup content to staging.

    - Page-type files: copied to courses/{courseId}/staging/pages/ for push
    - Placeholder-type files: checks that a corresponding brief already exists
      (briefs are saved at placeholder creation time via save_page(brief_data=...)).
      If a brief is missing, reports it as needing manual creation.

    Returns dict with promoted pages, briefs available, and items needing attention.
    """
    pages = list_pages(course_id)
    promoted = []
    briefs_available = []
    needs_attention = []
    subheaders_skipped = 0
    staging_paths = []

    for page in pages:
        if page["page_type"] == "page":
            result = promote_page(course_id, page["filename"])
            if result.get("ok"):
                promoted.append(page["filename"])
                staged_path = result.get("staged_path")
                if staged_path:
                    staging_paths.append(staged_path)
            else:
                needs_attention.append({
                    "filename": page["filename"],
                    "issue": result.get("error", "unknown"),
                })
        elif page["page_type"] == "subheader":
            subheaders_skipped += 1
        elif page["page_type"] == "placeholder":
            # Check if a brief was saved when this placeholder was created
            stem = page["filename"].replace(".html", "")
            module_num, brief_type = _resolve_placeholder_brief(
                stem, explicit_brief_type=page.get("brief_type"))
            if brief_type is not None:
                brief = load_brief(
                    course_id, brief_type, module_num, slug=page.get("brief_slug"))
                if brief:
                    briefs_available.append({
                        "filename": page["filename"],
                        "brief_type": brief_type,
                        "module_num": module_num,
                    })
                else:
                    needs_attention.append({
                        "filename": page["filename"],
                        "issue": f"Placeholder exists but no brief was saved "
                                 f"at creation time. Re-create this placeholder "
                                 f"using save_page(brief_data=...) to preserve "
                                 f"the structured content for course-build.",
                    })
            else:
                # External tool or unrecognized pattern — manual step
                needs_attention.append({
                    "filename": page["filename"],
                    "issue": "External tool placeholder — requires manual "
                             "configuration after course build.",
                })

    try:
        _emit_mockup_event(course_id, "mockup_promoted", {
            "pages_promoted": len(promoted),
            "briefs_promoted": len(briefs_available),
            "subheaders_skipped": subheaders_skipped,
            "staging_paths": staging_paths,
        })
    except Exception:
        pass

    return {
        "ok": True,
        "promoted": promoted,
        "briefs_available": briefs_available,
        "needs_attention": needs_attention,
        "subheaders_skipped": subheaders_skipped,
    }


# ============================================================
# List all mockups across courses
# ============================================================

def list_mockups():
    """Scan all course directories for mockup/manifest.json files.

    Returns list of manifest summaries.
    """
    root = get_plugin_root()
    courses_dir = os.path.join(root, "courses")
    if not os.path.isdir(courses_dir):
        return []

    results = []
    for entry in sorted(os.listdir(courses_dir)):
        manifest_path = os.path.join(
            courses_dir, entry, "mockup", "manifest.json")
        if os.path.exists(manifest_path):
            try:
                with open(manifest_path, "r", encoding="utf-8") as f:
                    manifest = json.load(f)
                manifest["_dir"] = os.path.join(courses_dir, entry, "mockup")
                results.append(manifest)
            except (json.JSONDecodeError, OSError):
                continue
    return results


# ============================================================
# Audit → Rationale Auto-Seed (Feature B)
# ============================================================

# HTML sentinel markers that bracket the auto-generated audit findings table.
# The seeder replaces content between these markers on each run (idempotent).
_SENTINEL_START = "<!-- AUDIT-FINDINGS-TABLE:START -->"
_SENTINEL_END = "<!-- AUDIT-FINDINGS-TABLE:END -->"

# Severity badge colors (inline styles)
_SEV_COLORS = {
    "critical": ("#8C1D40", "#ffffff"),   # maroon bg, white text
    "warning":  ("#b5540a", "#ffffff"),   # amber bg, white text
}


def _build_audit_table_html(findings):
    """Build the <table> HTML for audit findings.

    Columns: Gap · Evidence · Impact (3-column view; severity encoded as inline tag).
    Uses inline styles with maroon <th> background for preview-base.html rendering
    (after the recent contrast fix, th without inline background falls back to gray;
    rationale tables want maroon per ASUCB design standards).

    Args:
        findings: list of finding dicts from the audit JSON format:
            {id, category, summary, evidence_export, evidence_live, severity, impact}

    Returns:
        HTML string for the full <table> element.
    """
    # No font-weight here: <th> is bold by default, and Canvas strips
    # font-weight from inline styles anyway (verified live 2026-07-10).
    th_style = (
        'style="background:#8C1D40;color:#ffffff;padding:8px 12px;'
        'text-align:left;border:1px solid #ccc;"'
    )
    rows = []
    for i, f in enumerate(findings):
        sev = str(f.get("severity", "")).lower()
        bg_color, text_color = _SEV_COLORS.get(sev, ("#555", "#fff"))
        row_bg = "#fafafa" if i % 2 == 0 else "#f0f0f0"
        # Concatenate evidence fields
        ev_export = f.get("evidence_export", "")
        ev_live = f.get("evidence_live", "")
        evidence_html = ""
        if ev_export:
            evidence_html += f"<p>{ev_export}</p>"
        if ev_live:
            evidence_html += f"<p><em>{ev_live}</em></p>"
        if not evidence_html:
            evidence_html = "<p>—</p>"

        # <strong> instead of inline font-weight: Canvas strips font-weight
        # from inline styles (verified live 2026-07-10).
        sev_badge = (
            f'<span style="display:inline-block;padding:2px 7px;border-radius:4px;'
            f'background:{bg_color};color:{text_color};font-size:11px;'
            f'"><strong>{f.get("severity", "")}</strong></span>'
        )
        summary_html = (
            f'{sev_badge} {f.get("summary", f.get("id", ""))}'
        )
        impact_html = f.get("impact", "")
        td_style = f'style="padding:8px 12px;border:1px solid #ccc;background:{row_bg};vertical-align:top;"'
        rows.append(
            f"  <tr>\n"
            f"    <td {td_style}>{summary_html}</td>\n"
            f"    <td {td_style}>{evidence_html}</td>\n"
            f"    <td {td_style}>{impact_html}</td>\n"
            f"  </tr>"
        )

    rows_html = "\n".join(rows) if rows else (
        "  <tr><td colspan=\"3\" style=\"padding:8px 12px;\">No findings.</td></tr>"
    )
    return (
        "<table style=\"border-collapse:collapse;width:100%;margin:1em 0;\">\n"
        "  <thead>\n"
        f"  <tr><th {th_style}>Gap</th>"
        f"<th {th_style}>Evidence</th>"
        f"<th {th_style}>Impact</th></tr>\n"
        "  </thead>\n"
        f"  <tbody>\n{rows_html}\n  </tbody>\n"
        "</table>"
    )


def seed_rationale_from_audit(
    course_id,
    audit_json_path,
    rationale_page="00-redesign-rationale.html",
):
    """Read audit JSON and update the AUDIT-FINDINGS-TABLE sentinel block in
    the rationale page.

    Chosen audit source: criterion_evaluator.py --emit-json PATH
    Rationale: criterion_evaluator.py is the canonical ASUCB audit orchestrator
    that runs the full deterministic criterion suite + collects phase-2 findings
    (rubrics, MLO drift, Bloom alignment, CLO orphans). Its --emit-json flag
    produces exactly the format this seeder consumes. The individual audit_*.py
    scripts cover sub-domains but not the full picture; using the orchestrator
    ensures the rationale table reflects the complete audit, not a subset.

    Operation is idempotent: re-running replaces content between sentinels;
    everything else in the page is preserved unchanged.

    Args:
        course_id: Canvas course ID
        audit_json_path: Path to audit findings JSON (from criterion_evaluator
            --emit-json or a hand-crafted fixture with the same schema).
        rationale_page: Filename in the course mockup pages/ directory.
            Defaults to '00-redesign-rationale.html'.

    Returns:
        {
            "ok": True/False,
            "path": str (path to updated page),
            "findings_count": int,
            "error": str (only present on failure)
        }
    """
    # Load audit JSON
    try:
        with open(audit_json_path, "r", encoding="utf-8") as f:
            audit_doc = json.load(f)
    except FileNotFoundError:
        return {"ok": False, "error": f"Audit JSON not found: {audit_json_path}"}
    except json.JSONDecodeError as exc:
        return {"ok": False, "error": f"Audit JSON parse error: {exc}"}

    findings = audit_doc.get("findings", [])

    # Locate rationale page
    pages_dir = _mockup_dir(course_id, "pages")
    if not rationale_page.endswith(".html"):
        rationale_page += ".html"
    page_path = os.path.join(pages_dir, rationale_page)
    if not os.path.exists(page_path):
        return {
            "ok": False,
            "error": (
                f"Rationale page not found: {rationale_page}. "
                f"Create it first with save_page() or mockup_manager "
                f"--course-id {course_id}."
            ),
        }

    with open(page_path, "r", encoding="utf-8") as f:
        content = f.read()

    table_html = _build_audit_table_html(findings)
    replacement_block = (
        f"{_SENTINEL_START}\n{table_html}\n{_SENTINEL_END}"
    )

    if _SENTINEL_START in content and _SENTINEL_END in content:
        # Idempotent replace: substitute everything between sentinels
        start_idx = content.index(_SENTINEL_START)
        end_idx = content.index(_SENTINEL_END) + len(_SENTINEL_END)
        content = content[:start_idx] + replacement_block + content[end_idx:]
    else:
        # No sentinel — try heuristic: insert after <h2>1. Current Course
        heuristic = re.search(
            r"(<h2[^>]*>1\.[^<]*Current[^<]*</h2>)", content, re.IGNORECASE
        )
        if heuristic:
            insert_after = heuristic.end()
            content = (
                content[:insert_after]
                + "\n" + replacement_block + "\n"
                + content[insert_after:]
            )
        else:
            return {
                "ok": False,
                "error": (
                    f"Could not find sentinel block ({_SENTINEL_START!r} ... "
                    f"{_SENTINEL_END!r}) or fallback heading "
                    "'<h2>1. Current Course — What Was Found</h2>' in "
                    f"{rationale_page}. Add the sentinel block where you want "
                    "the audit findings table to appear, then re-run."
                ),
            }

    with open(page_path, "w", encoding="utf-8") as f:
        f.write(content)

    return {
        "ok": True,
        "path": page_path,
        "findings_count": len(findings),
    }


# ============================================================
# CLI
# ============================================================


def _read_user_identity():
    """Return a best-effort user identifier for status_history entries."""
    # Try MEMORY.md userEmail
    try:
        import re as _re
        mem_path = os.path.join(get_plugin_root(), ".claude", "projects",
                                "-Users-bespined-Desktop-ASU-Canvas-Builder",
                                "memory", "MEMORY.md")
        if os.path.exists(mem_path):
            with open(mem_path, "r", encoding="utf-8") as f:
                text = f.read()
            m = _re.search(r"userEmail[\s\S]*?(\S+@\S+\.\S+)", text)
            if m:
                return m.group(1)
    except Exception:
        pass
    return os.environ.get("USER", "unknown")


def set_approval_status(course_id, new_status, note="", by=""):
    """Set mockup approval status and append to status_history.

    Creates a minimal manifest if one does not exist.
    Returns the updated manifest dict.
    Raises ValueError on invalid status.
    """
    if new_status not in VALID_STATUSES:
        raise ValueError(
            f"Invalid status {new_status!r}. Must be one of: {', '.join(VALID_STATUSES)}"
        )
    manifest = load_manifest(course_id)
    if manifest is None:
        # Create minimal manifest so we can track status
        manifest = {
            "courseId": str(course_id),
            "courseName": f"Course {course_id}",
            "term": "",
            "created": datetime.now(timezone.utc).isoformat(),
            "updated": datetime.now(timezone.utc).isoformat(),
            "version": 1,
            "faculty": "",
            "status": "pending",
            "last_status_change": datetime.now(timezone.utc).isoformat(),
            "status_history": [],
        }
        _mockup_dir(course_id)  # ensure dir exists

    if not by:
        by = _read_user_identity()

    now_iso = datetime.now(timezone.utc).isoformat()
    entry = {"status": new_status, "at": now_iso, "by": by}
    if note:
        entry["note"] = note

    manifest.setdefault("status_history", []).append(entry)
    manifest["status"] = new_status
    manifest["last_status_change"] = now_iso
    manifest["updated"] = now_iso

    path = _manifest_path(course_id)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(manifest, f, indent=2, ensure_ascii=False)

    _emit_mockup_event(course_id, "mockup_status_change", {
        "status": new_status,
        "by": by,
        "note": note,
    })
    return manifest


def main():
    parser = argparse.ArgumentParser(description="ASUCB Mockup Manager")
    parser.add_argument("--course-id", required=True, help="Canvas course ID")

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--create-manifest", action="store_true",
                       help="Create manifest.json")
    group.add_argument("--show-manifest", action="store_true",
                       help="Show manifest")
    group.add_argument("--list-pages", action="store_true",
                       help="List mockup pages")
    group.add_argument("--list-briefs", action="store_true",
                       help="List assessment briefs")
    group.add_argument("--generate-preview", action="store_true",
                       help="Generate course-named preview HTML")
    group.add_argument("--promote", metavar="FILENAME",
                       help="Promote a page to staging")
    group.add_argument("--promote-all", action="store_true",
                       help="Promote all pages to staging")
    group.add_argument("--list-all", action="store_true",
                       help="List all mockups across courses")
    group.add_argument("--set-status", metavar="STATUS",
                       help=f"Set approval status ({', '.join(VALID_STATUSES)})")
    group.add_argument("--rename", nargs=2, metavar=("OLD", "NEW"),
                       help="Rename a mockup page (moves HTML + .meta.json; updates filename in meta)")
    group.add_argument("--reorder-module", metavar="MODULE_KEY",
                       help="Renumber all pages within a module prefix (e.g., m0, m3, m7). "
                            "Re-applies sequential NN- prefixes preserving canonical Canvas order.")
    group.add_argument("--audience-lint", action="store_true",
                       help="Scan all mockup pages for audience-forbidden tokens. "
                            "Exits 0 with warnings unless --strict is also set.")
    group.add_argument("--seed-rationale-from-audit", metavar="AUDIT_JSON",
                       help="Read audit findings JSON and populate/replace the "
                            "AUDIT-FINDINGS-TABLE sentinel block in the rationale page.")

    parser.add_argument("--name", default="", help="Course name (for create)")
    parser.add_argument("--term", default="", help="Term (for create)")
    parser.add_argument("--faculty", default="", help="Faculty name (for create)")
    parser.add_argument("--note", default="", help="Note for status change")
    parser.add_argument("--by", default="", help="Who is setting the status")
    parser.add_argument("--audience", default=None,
                        choices=AUDIENCE_LEVELS,
                        help="Override audience level for --audience-lint "
                             "(lints ALL pages against this level's forbidden list).")
    parser.add_argument("--strict", action="store_true",
                        help="When used with --audience-lint, exit non-zero if any "
                             "violations are found.")
    parser.add_argument("--rationale-page", default="00-redesign-rationale.html",
                        help="Rationale page filename for --seed-rationale-from-audit "
                             "(default: 00-redesign-rationale.html).")

    args = parser.parse_args()
    if getattr(args, 'course_id', None) is not None:
        try:
            args.course_id = validate_course_id(args.course_id)
        except ValueError as exc:
            parser.error(str(exc))

    if args.create_manifest:
        result = create_manifest(args.course_id, args.name, args.term,
                                 args.faculty)
        print(json.dumps(result, indent=2))

    elif args.show_manifest:
        manifest = load_manifest(args.course_id)
        if manifest:
            print(json.dumps(manifest, indent=2))
        else:
            print(json.dumps({"ok": False, "error": "No manifest found"}))
            sys.exit(1)

    elif args.list_pages:
        pages = list_pages(args.course_id)
        print(json.dumps({"ok": True, "pages": pages}, indent=2))

    elif args.list_briefs:
        briefs = list_briefs(args.course_id)
        print(json.dumps({"ok": True, "briefs": briefs}, indent=2))

    elif args.generate_preview:
        result = generate_preview(args.course_id)
        print(json.dumps(result, indent=2))

    elif args.promote:
        result = promote_page(args.course_id, args.promote)
        print(json.dumps(result, indent=2))

    elif args.promote_all:
        result = promote_all(args.course_id)
        print(json.dumps(result, indent=2))

    elif args.list_all:
        mockups = list_mockups()
        print(json.dumps({"ok": True, "mockups": mockups}, indent=2))

    elif args.set_status:
        try:
            result = set_approval_status(
                args.course_id, args.set_status,
                note=args.note, by=args.by
            )
            print(json.dumps({"ok": True, "manifest": result}, indent=2))
        except ValueError as exc:
            print(json.dumps({"ok": False, "error": str(exc)}))
            sys.exit(1)

    elif args.rename:
        old, new = args.rename
        result = rename_page(args.course_id, old, new)
        print(json.dumps(result, indent=2))
        if not result.get("ok"):
            sys.exit(1)

    elif args.reorder_module:
        result = reorder_module(args.course_id, args.reorder_module)
        print(json.dumps(result, indent=2))
        if not result.get("ok"):
            sys.exit(1)

    elif args.audience_lint:
        result = audience_lint(args.course_id, override_audience=args.audience)
        print(json.dumps(result, indent=2))
        if not result.get("ok"):
            sys.exit(1)
        if args.strict and result.get("violations"):
            sys.exit(1)

    elif args.seed_rationale_from_audit:
        result = seed_rationale_from_audit(
            args.course_id,
            args.seed_rationale_from_audit,
            rationale_page=args.rationale_page,
        )
        print(json.dumps(result, indent=2))
        if not result.get("ok"):
            sys.exit(1)


if __name__ == "__main__":
    main()
