#!/usr/bin/env python3
"""Preflight checks — lightweight content-type validation for shift-left quality.

Runs a SUBSET of quality checks against partial data (one page, one assessment,
one module) without requiring the full course. Designed to fire during content
creation and staging, not just during full audits.

Reuses HTML parsers from deterministic_checks.py and Bloom's utilities from
alignment_validator.py (via scripts/blooms.py).

Usage:
    # As a library (imported by staging_manager, skills, etc.)
    from preflight_checks import check_page, check_module, Issue

    issues = check_page(html, context={"page_type": "overview", "objectives": [...]})

    # As CLI (for testing / manual runs)
    python preflight_checks.py --check-page --html-file staging/m1-overview.html
    python preflight_checks.py --check-module --module 1 --config course-config.json
"""

import argparse
import json
import re
import sys
from dataclasses import dataclass, asdict
from html.parser import HTMLParser
from pathlib import Path
from typing import Optional

try:
    import yaml as _yaml
except ImportError:
    _yaml = None

# Logging
try:
    from idw_logger import get_logger
    _log = get_logger("preflight_checks")
except ImportError:
    import logging
    _log = logging.getLogger("preflight_checks")

# Bloom's utilities — canonical source is blooms.py
from blooms import BLOOMS_VERBS, UNMEASURABLE_VERBS, classify_blooms, is_measurable
# Shared HTML helper (QB2 dedup).
from html_utils import strip_html_preserve_ws as _strip_html_preserve_ws_shared

# ASU brand checks — advisory sidecar (D4: sidecar-only, never blocks push)
try:
    from check_asu_brand import check_file as _brand_check_file
    _BRAND_CHECK_AVAILABLE = True
except ImportError:
    _BRAND_CHECK_AVAILABLE = False

BLOOMS_RANK = {"Remember": 1, "Understand": 2, "Apply": 3,
               "Analyze": 4, "Evaluate": 5, "Create": 6}


# ── Issue dataclass ────────────────────────────────────────────────────────

@dataclass
class Issue:
    """A quality issue found during preflight checks."""
    criterion_id: str       # Maps to standards.yaml (e.g., "22.1")
    severity: str           # "error" | "warning" | "info"
    message: str            # Human-readable, no jargon
    location: str           # Where in the content (e.g., "line 14", "image 2")
    fixable: bool           # Can IDW auto-fix this?
    fix_hint: Optional[str] = None  # Suggestion if not auto-fixable

    def to_dict(self):
        return asdict(self)


# ── HTML Parsers (reused from deterministic_checks.py pattern) ─────────────

class _HeadingParser(HTMLParser):
    """Extract heading levels and their line positions from HTML."""
    def __init__(self):
        super().__init__()
        self.headings = []  # [(level, line, text)]
        self._in_heading = False
        self._heading_level = 0
        self._heading_text = ""
        self._heading_line = 0

    def handle_starttag(self, tag, attrs):
        if re.match(r"h[1-6]$", tag):
            self._in_heading = True
            self._heading_level = int(tag[1])
            self._heading_text = ""
            self._heading_line = self.getpos()[0]

    def handle_data(self, data):
        if self._in_heading:
            self._heading_text += data

    def handle_endtag(self, tag):
        if self._in_heading and re.match(r"h[1-6]$", tag):
            self._in_heading = False
            self.headings.append((self._heading_level, self._heading_line,
                                  self._heading_text.strip()))


class _ImgParser(HTMLParser):
    """Extract images with alt text, src, and line position."""
    def __init__(self):
        super().__init__()
        self.images = []  # [{"src", "alt", "role", "line"}]

    def handle_starttag(self, tag, attrs):
        if tag == "img":
            d = dict(attrs)
            self.images.append({
                "src": d.get("src", ""),
                "alt": d.get("alt"),
                "role": d.get("role", ""),
                "line": self.getpos()[0],
            })


class _LinkParser(HTMLParser):
    """Extract link text and href with line position."""
    BAD_TEXT = re.compile(
        r"^(click here|here|link|read more|more|https?://|www\.)$", re.I
    )

    def __init__(self):
        super().__init__()
        self.links = []  # [{"text", "href", "line"}]
        self._in_a = False
        self._text = ""
        self._href = ""
        self._line = 0

    def handle_starttag(self, tag, attrs):
        if tag == "a":
            self._in_a = True
            self._text = ""
            d = dict(attrs)
            self._href = d.get("href", "")
            self._line = self.getpos()[0]

    def handle_data(self, data):
        if self._in_a:
            self._text += data

    def handle_endtag(self, tag):
        if tag == "a" and self._in_a:
            self._in_a = False
            self.links.append({
                "text": self._text.strip(),
                "href": self._href,
                "line": self._line,
            })


class _VideoEmbedParser(HTMLParser):
    """Detect video embeds (iframe, video, embed) and transcript placeholders."""
    def __init__(self):
        super().__init__()
        self.video_embeds = []  # [{"type", "src", "line"}]
        self._full_text = ""

    def handle_starttag(self, tag, attrs):
        d = dict(attrs)
        if tag == "iframe":
            src = d.get("src", "")
            if any(host in src.lower() for host in
                   ["youtube", "vimeo", "wistia", "kaltura", "panopto",
                    "instructuremedia", "playposit", "arc.net"]):
                self.video_embeds.append({"type": "iframe", "src": src,
                                          "line": self.getpos()[0]})
        elif tag == "video":
            self.video_embeds.append({"type": "video", "src": d.get("src", ""),
                                      "line": self.getpos()[0]})

    def handle_data(self, data):
        self._full_text += data

    def has_transcript_placeholder(self):
        """Check if the page contains transcript-related text."""
        text_lower = self._full_text.lower()
        return any(kw in text_lower for kw in
                   ["transcript", "caption", "closed caption", "subtitles",
                    "text alternative", "audio description"])


def _strip_html(html):
    """Extract plain text from HTML, preserving whitespace inside text nodes.

    Thin alias — delegates to :func:`html_utils.strip_html_preserve_ws`.
    Kept here by name so existing callers (including tests that import
    ``preflight_checks._strip_html`` directly) continue to work.
    """
    return _strip_html_preserve_ws_shared(html)


# ── Placeholder detection ─────────────────────────────────────────────────

PLACEHOLDER_PATTERNS = [
    re.compile(r"\[TODO[:\s].*?\]", re.I),
    re.compile(r"\[TBD\]", re.I),
    re.compile(r"\[PLACEHOLDER\]", re.I),
    re.compile(r"\[INSERT\s.*?\]", re.I),
    re.compile(r"\[FACULTY[:\s].*?\]", re.I),
    re.compile(r"\[UPDATE[:\s].*?\]", re.I),
    re.compile(r"\[\s*Instructors?[:\s].*?\]", re.I),      # [Instructors: ...] and [ Instructors: ...]
    re.compile(r"\[\s*Note\s+for\s+Instructors?", re.I),   # [ Note for Instructors: ...] (ASU template)
    re.compile(r"Lorem ipsum", re.I),
    re.compile(r"\[YOUR\s.*?\]", re.I),
    # ASU Canvas template-specific placeholders
    re.compile(r"\[ASU\s+\d+[:\s]", re.I),                 # [ASU 123: Title]
    re.compile(r"\[Title\]", re.I),                         # bare [Title] placeholder
    re.compile(r"\[Learning Materials or Topic\]", re.I),
    re.compile(r"\[Name of Assessment\]", re.I),
    re.compile(r"\[time in hours", re.I),                   # [time in hours/minutes]
    re.compile(r"background-color:\s*#fbeeb8", re.I),       # Yellow highlight = instructor-only placeholder
    re.compile(r"\[specify\s", re.I),                       # [Specify ...]
    re.compile(r"\[add\s.*?\]", re.I),                      # [Add ...]
    re.compile(r"\[LINK\s+TO\]", re.I),                     # [LINK TO ...]
]


def _find_placeholders(text):
    """Find placeholder text patterns. Returns list of (match_text, approx_position)."""
    found = []
    for pat in PLACEHOLDER_PATTERNS:
        for m in pat.finditer(text):
            found.append((m.group(), m.start()))
    return found


# ═══════════════════════════════════════════════════════════════════════════
# FEATURE A: Bloom jargon lint
# ═══════════════════════════════════════════════════════════════════════════

_BLOOM_JARGON_PATTERNS = [
    re.compile(r"\bRemember level\b", re.I),
    re.compile(r"\bApply level\b", re.I),
    re.compile(r"\bUnderstand level\b", re.I),
    re.compile(r"\bAnalyze level\b", re.I),
    re.compile(r"\bEvaluate level\b", re.I),
    re.compile(r"\bCreate level\b", re.I),
    re.compile(r"\bBloom\b", re.I),
    re.compile(r"\bcognitive level\b", re.I),
    re.compile(r"\bcognitive demand\b", re.I),
    re.compile(r"\bcognitive load level\b", re.I),
]


def _context_snippet(text: str, match_start: int, match_end: int, window: int = 80) -> str:
    """Return up to `window` chars of surrounding context around a match."""
    start = max(0, match_start - window // 2)
    end = min(len(text), match_end + window // 2)
    snippet = text[start:end].replace("\n", " ").strip()
    if start > 0:
        snippet = "…" + snippet
    if end < len(text):
        snippet = snippet + "…"
    return snippet


def check_bloom_jargon(content: str, question_id: str = "") -> list:
    """Warn when faculty-facing content uses Bloom taxonomy jargon.

    Scans both raw HTML and plain text extracted from it. Emits a 'warning'
    (never a blocking error) for each banned term found. Faculty are content
    experts, not IDs — Bloom level names are meaningless to them.

    Args:
        content: HTML or plain text string to scan.
        question_id: Optional identifier (e.g. question number) for location tag.

    Returns:
        List of Issue objects with criterion_id 'bloom_jargon'.
    """
    if not content:
        return []

    issues = []
    plain = _strip_html(content)
    # Check both raw HTML (catches text in attr values) and plain text
    sources = [("HTML", content), ("text", plain)]
    seen_terms = set()

    for source_label, source_text in sources:
        for pat in _BLOOM_JARGON_PATTERNS:
            for m in pat.finditer(source_text):
                term = m.group().lower()
                if term in seen_terms:
                    continue
                seen_terms.add(term)
                snippet = _context_snippet(source_text, m.start(), m.end())
                loc_parts = []
                if question_id:
                    loc_parts.append(f"question {question_id}")
                loc_parts.append(f"matched \"{m.group()}\"")
                loc = " > ".join(loc_parts)
                issues.append(Issue(
                    "bloom_jargon",
                    "warning",
                    (
                        f"Faculty-facing content uses Bloom jargon: \"{m.group()}\". "
                        "Use plain language — describe what the item asks vs. what the MLO calls for."
                    ),
                    loc,
                    False,
                    f"Replace with plain language. Context: \"{snippet}\"",
                ))

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# FEATURE B: Collaborative tone lint for REPLACE rationales
# ═══════════════════════════════════════════════════════════════════════════

# Load tone denylist from YAML alongside the skill file; fall back to
# hard-coded defaults if the file is missing or PyYAML is not installed.
_TONE_DENYLIST_PATH = (
    Path(__file__).resolve().parents[1]
    / "skills" / "course-mockup" / "tone_denylist.yaml"
)

_DEFAULT_DIRECTIVE_PHRASES = [
    r"MLO \d+ requires",
    r"the current item is wrong",
    r"you should",
    r"you must",
    r"the item fails to",
    r"we need to replace",
    r"students fail to",
    r"this is incorrect because",
]

_DEFAULT_COLLABORATIVE_PHRASES = [
    "MLO X calls for",
    "if you'd like an item that",
    "open to your preferred direction",
    "this could be strengthened by",
    "happy to revise either direction",
    "one possibility is",
]


def _load_tone_denylist() -> tuple[list[re.Pattern], list[str]]:
    """Load directive_phrases and collaborative_phrases from tone_denylist.yaml."""
    if _yaml is not None and _TONE_DENYLIST_PATH.exists():
        try:
            data = _yaml.safe_load(_TONE_DENYLIST_PATH.read_text(encoding="utf-8")) or {}
            directives = [re.compile(p, re.I) for p in data.get("directive_phrases", _DEFAULT_DIRECTIVE_PHRASES)]
            collab = data.get("collaborative_phrases", _DEFAULT_COLLABORATIVE_PHRASES)
            return directives, collab
        except Exception:
            pass
    directives = [re.compile(p, re.I) for p in _DEFAULT_DIRECTIVE_PHRASES]
    return directives, _DEFAULT_COLLABORATIVE_PHRASES


_COLLABORATIVE_ALTERNATIVES = {
    r"MLO \d+ requires": "MLO X calls for",
    r"the current item is wrong": "this could be strengthened by",
    r"you should": "one possibility is",
    r"you must": "one possibility is",
    r"the item fails to": "if you'd like an item that",
    r"we need to replace": "one possibility is",
    r"students fail to": "if you'd like an item that",
    r"this is incorrect because": "this could be strengthened by",
}


def _is_replace_tagged(content: str, decision_yaml: dict = None) -> bool:
    """Return True if content appears to be a REPLACE-tagged rationale."""
    if decision_yaml is not None:
        return str(decision_yaml.get("tag", "")).upper() == "REPLACE"
    # Detect REPLACE badge HTML pattern or plain-text marker
    replace_html_pattern = re.compile(
        r'<span[^>]*>REPLACE</span>|REPLACE_BADGE|>\s*REPLACE\s*<', re.I
    )
    return bool(replace_html_pattern.search(content))


def check_replace_tone(content: str, decision_yaml: dict = None) -> list:
    """Warn when REPLACE rationales use directive rather than collaborative voice.

    Scans REPLACE-tagged rationales for denylist phrase patterns. Emits
    'warning' (never blocking). All IDs emit this check; the denylist is
    loaded from skills/course-mockup/tone_denylist.yaml.

    Args:
        content: HTML or plain text to scan.
        decision_yaml: Optional decision dict with a 'tag' key (from decisions YAML).
                       If None, REPLACE detection falls back to badge HTML scan.

    Returns:
        List of Issue objects with criterion_id 'replace_tone_directive'.
    """
    if not content:
        return []
    if not _is_replace_tagged(content, decision_yaml):
        return []

    plain = _strip_html(content)
    directive_patterns, _ = _load_tone_denylist()
    issues = []
    seen = set()

    for pat in directive_patterns:
        for m in pat.finditer(plain):
            key = m.group().lower()
            if key in seen:
                continue
            seen.add(key)
            # Look up a suggested alternative based on the raw pattern string
            alternative = "collaborative phrasing (see tone_denylist.yaml allow-list)"
            for raw_pat, alt in _COLLABORATIVE_ALTERNATIVES.items():
                if re.search(raw_pat, m.group(), re.I):
                    alternative = f'"{alt}"'
                    break
            issues.append(Issue(
                "replace_tone_directive",
                "warning",
                (
                    f"REPLACE rationale uses directive phrasing: \"{m.group()}\". "
                    "Faculty are partners — use collaborative voice."
                ),
                f"matched \"{m.group()}\"",
                False,
                f"Suggested alternative: {alternative}",
            ))

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# FEATURE C: Badge style lint
# ═══════════════════════════════════════════════════════════════════════════

# Canonical ASU-brand pill badge HTML — single source of truth.
CANONICAL_KEEP_BADGE = (
    '<span style="display:inline-block; background:#78BE20; color:#191919; '
    'padding:3px 9px; border-radius:4px; font-size:12px;">KEEP</span>'
)
CANONICAL_REPLACE_BADGE = (
    '<span style="display:inline-block; background:#8C1D40; color:#FFFFFF; '
    'padding:3px 9px; border-radius:4px; font-size:12px;">REPLACE</span>'
)
CANONICAL_ADD_BADGE = (
    '<span style="display:inline-block; background:#FFC627; color:#191919; '
    'padding:3px 9px; border-radius:4px; font-size:12px;">ADD</span>'
)
CANONICAL_OPTIONAL_CHIP = (
    '<span style="display:inline-block; border:1px solid #8C1D40; color:#8C1D40; '
    'padding:2px 8px; border-radius:4px; font-size:12px;">OPTIONAL</span>'
)

# Canonical background color for each badge label (uppercase hex).
# A badge span that omits the canonical color for its label triggers a warning.
_CANONICAL_BADGE_COLORS = {
    "KEEP": "#78BE20",       # ASU green
    "REPLACE": "#8C1D40",    # ASU maroon
    "ADD": "#FFC627",        # ASU gold
    # OPTIONAL uses a border (outlined) style — no background canonical color to enforce
}

# Known non-canonical background colors used in legacy/P3 output, keyed by label.
# Any of these in the badge span signals a style mismatch (regardless of case).
_LEGACY_BADGE_COLORS = {
    "KEEP": ["#03893d", "#2d8659"],       # flat greens from P3 / pre-canonical
    "REPLACE": ["#ffc627", "#f7b500"],    # gold/orange (P3 mistakenly used gold for REPLACE)
    "ADD": ["#0374b5"],                    # P3 blue
    "OPTIONAL": ["#6b7280"],              # gray (P3)
}

# Badge span detection pattern — finds <span ...>LABEL</span> or variants
_BADGE_SPAN_RE = re.compile(
    r'<span\s[^>]*>(?P<label>KEEP|REPLACE|ADD|OPTIONAL)</span>',
    re.I,
)


def check_badge_style(content: str) -> list:
    """Warn when mockup quiz badge HTML doesn't match ASU canonical pill style.

    Scans HTML content for KEEP/REPLACE/ADD/OPTIONAL badge spans. Flags any
    badge that uses a known legacy/non-canonical color, or (for KEEP/REPLACE/ADD)
    that is missing its canonical color entirely. Never blocks.

    Args:
        content: HTML string to scan.

    Returns:
        List of Issue objects with criterion_id 'badge_style_mismatch'.
    """
    if not content:
        return []

    issues = []
    seen_offenders = set()

    canonical_map = {
        "KEEP": CANONICAL_KEEP_BADGE,
        "REPLACE": CANONICAL_REPLACE_BADGE,
        "ADD": CANONICAL_ADD_BADGE,
        "OPTIONAL": CANONICAL_OPTIONAL_CHIP,
    }

    for m in _BADGE_SPAN_RE.finditer(content):
        badge_html = m.group(0)
        label = m.group("label").upper()
        span_lower = badge_html.lower()

        # 1. Flag known legacy colors
        for bad_color in _LEGACY_BADGE_COLORS.get(label, []):
            if bad_color.lower() in span_lower:
                key = (label, bad_color.lower())
                if key not in seen_offenders:
                    seen_offenders.add(key)
                    issues.append(Issue(
                        "badge_style_mismatch",
                        "warning",
                        (
                            f"{label} badge uses non-canonical color {bad_color}. "
                            "Use ASU-brand pill badge HTML."
                        ),
                        f"badge: {badge_html[:80]}",
                        False,
                        f"Replace with canonical: {canonical_map.get(label, '')}",
                    ))
                break  # one warning per badge occurrence

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# FEATURE D: CLO-only consistency lint
# ═══════════════════════════════════════════════════════════════════════════

# Patterns that detect the CLO-ONLY chip and the yellow <mark> callout in HTML.
_CLO_ONLY_CHIP_RE = re.compile(r'CLO\s*ONLY', re.I)
_CLO_MARK_CALLOUT_RE = re.compile(
    r'<mark[^>]*style[^>]*background(?:-color)?:\s*#fff2a8[^>]*>',
    re.I,
)


def check_clo_only_consistency(content: str, decided_at: str | None = None) -> list:
    """Warn when CLO-only chip and yellow mark callout are out of sync.

    Three sub-checks (all warnings, never blocking):
      clo_missing_callout  — chip present but no yellow mark callout wrapping the rationale
      clo_missing_chip     — yellow mark callout present but no CLO-ONLY chip in the badge row
      clo_pathway_unresolved — CLO-flag item has been present more than 14 days
                               (requires decided_at ISO timestamp; skipped if absent)

    Args:
        content: HTML string of a staged quiz question or page section.
        decided_at: Optional ISO-8601 date string (YYYY-MM-DD) from the .meta.json
                    `decided_at` field.  If absent, the unresolved-age check is skipped.

    Returns:
        List of Issue objects with criterion_id 'clo_missing_callout',
        'clo_missing_chip', or 'clo_pathway_unresolved'.
    """
    if not content:
        return []

    has_chip = bool(_CLO_ONLY_CHIP_RE.search(content))
    has_callout = bool(_CLO_MARK_CALLOUT_RE.search(content))

    issues = []

    if has_chip and not has_callout:
        issues.append(Issue(
            "clo_missing_callout",
            "warning",
            (
                "CLO-ONLY chip is present but no yellow faculty-flag callout wraps the rationale. "
                "Use `background-color: #fff2a8` on the <mark> element."
            ),
            "badge row",
            False,
            "Add a <mark style=\"background-color: #fff2a8; color: inherit;\"> block around the rationale.",
        ))

    if has_callout and not has_chip:
        issues.append(Issue(
            "clo_missing_chip",
            "warning",
            (
                "A yellow CLO faculty-flag callout is present but the CLO-ONLY chip is missing from "
                "the badge row. Add the CLO_ONLY_CHIP after the primary tag badge."
            ),
            "badge row",
            False,
            "Append the CLO_ONLY_CHIP span immediately after the KEEP/MOVE/DROP badge.",
        ))

    if (has_chip or has_callout) and decided_at is not None:
        import datetime
        try:
            decided = datetime.date.fromisoformat(str(decided_at))
            age_days = (datetime.date.today() - decided).days
            if age_days > 14:
                issues.append(Issue(
                    "clo_pathway_unresolved",
                    "warning",
                    (
                        f"CLO-only flag item has been unresolved for {age_days} days "
                        "(more than the 14-day review window). Faculty decision is overdue."
                    ),
                    f"decided_at: {decided_at}",
                    False,
                    "Follow up with faculty to choose KEEP/MOVE/DROP for this item.",
                ))
        except (ValueError, TypeError):
            pass  # Unparseable timestamp — skip age check silently.

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# WCAG 2.1 A+AA static checks (shared parser from wcag_checks.py, PR2)
# ═══════════════════════════════════════════════════════════════════════════

# WCAG findings ride a single criterion namespace so they sort + filter
# together. The full sub-criterion is appended (e.g. "a11y.wcag:1.4.3").
_WCAG_NS = "a11y.wcag"

# Document-level criteria that are meaningless on a body fragment (Canvas page
# bodies have no <html lang> or <meta viewport>). They belong to the
# interactive full-document deploy gate, not preflight. Suppressed in fragment mode.
_WCAG_FRAGMENT_SUPPRESS = {"WCAG 3.1.1", "WCAG 1.4.10"}

# Per-sub-criterion manual-review hints for WCAG Issues.
_WCAG_FIX_HINTS = {
    "1.1.1": "Add descriptive alt text, or mark decorative with alt=\"\" / role=\"presentation\".",
    "1.3.1": "Fix heading order: start at H1 (or H2 in a canvas-wrapped fragment), no skipped levels.",
    "1.4.1": "Color alone is signalling the link. Keep an underline or other non-color cue.",
    "1.4.3": "Raise text/background contrast to at least 4.5:1.",
    "1.4.4": "Let users zoom: drop user-scalable=no / maximum-scale and avoid fixed px on body text.",
    "2.2.2": "Remove the auto-moving element; provide pause/stop control instead.",
    "2.4.2": "Give this item a descriptive title.",
    "2.4.3": "Use tabindex of 0 or -1 only; positive values break focus order.",
    "4.1.2": "Add aria-label, aria-labelledby, or an associated <label> for this control.",
}

# 1.4.1 narrow signal: an <a> whose inline style sets a color AND removes the
# underline (text-decoration:none); color becomes the only distinguisher.
_WCAG_A_TAG = re.compile(r"<a\b[^>]*\bstyle\s*=\s*([\"'])(?P<style>.*?)\1[^>]*>", re.DOTALL | re.I)

# 1.4.4 viewport zoom-suppression signals.
_WCAG_VIEWPORT = re.compile(
    r"<meta\b[^>]*name\s*=\s*([\"'])viewport\1[^>]*content\s*=\s*([\"'])(?P<content>.*?)\2",
    re.DOTALL | re.I,
)


def _wcag_sc(code: str) -> str:
    """'WCAG 1.4.3' -> '1.4.3'. Falls back to the raw code if unparseable."""
    m = re.search(r"(\d+(?:\.\d+)+)", code or "")
    return m.group(1) if m else (code or "").strip()


def _wcag_extra_checks(html: str) -> list:
    """Net-new static signals not covered by WCAGContentParser.

    Returns plain wcag_checks-style dicts so the same adapter maps them.
    All info-level / manual-review.
    """
    found = []
    # 1.4.1: de-underlined colored link.
    for m in _WCAG_A_TAG.finditer(html or ""):
        style = m.group("style").lower()
        if "color:" in style and re.search(r"text-decoration\s*:\s*none", style):
            found.append({
                "path": "", "code": "WCAG 1.4.1",
                "message": "Link uses color with no underline; color may be the only cue.",
                "element": "a",
            })
    # 1.4.4: viewport blocks zoom.
    for m in _WCAG_VIEWPORT.finditer(html or ""):
        content = m.group("content").lower()
        blocks = "user-scalable=no" in content.replace(" ", "")
        max_scale = re.search(r"maximum-scale\s*=\s*([0-9.]+)", content)
        if max_scale:
            try:
                blocks = blocks or float(max_scale.group(1)) < 2
            except ValueError:
                pass
        if blocks:
            found.append({
                "path": "", "code": "WCAG 1.4.4",
                "message": "Viewport meta blocks zoom (user-scalable=no or maximum-scale<2).",
                "element": "meta",
            })
    return found


def _wcag_issues(html: str, *, fragment: bool = True) -> list:
    """Run the shared WCAG parser on an HTML fragment; return deduped Issues.

    Failures -> 'warning', warnings -> 'info'. Severity floor: never 'error'
    (these are additive and must not block the push gate on rollout). Dedupe
    by (criterion_id, message) so a reused under-contrast component fires once.
    In fragment mode, document-level criteria (3.1.1, 1.4.10) are dropped.
    """
    if not html or not html.strip():
        return []

    # Lazy import to avoid load-order coupling with wcag_checks.
    from wcag_checks import WCAGContentParser

    parser = WCAGContentParser("fragment")
    try:
        parser.feed(html)
        parser.close()
        parser.finalize()
    except (ValueError, AssertionError):
        pass

    raw = [(f, "warning") for f in parser.failures] + [(w, "info") for w in parser.warnings]
    raw += [(f, "info") for f in _wcag_extra_checks(html)]

    issues = []
    seen = set()
    for finding, severity in raw:
        code = finding.get("code", "")
        if fragment and code in _WCAG_FRAGMENT_SUPPRESS:
            continue
        if code == "WARNING":
            continue  # legacy interactive-gate review note; not a WCAG SC
        sc = _wcag_sc(code)
        message = finding.get("message", "")
        key = (sc, message)
        if key in seen:
            continue
        seen.add(key)
        issues.append(Issue(
            f"{_WCAG_NS}:{sc}",
            severity,
            message,
            finding.get("element", "body"),
            False,
            _WCAG_FIX_HINTS.get(sc, "Manual accessibility review required."),
        ))
    return issues


def _wcag_title_issue(title: str, kind: str = "item") -> list:
    """WCAG 2.4.2: sourced from object metadata, NOT body HTML."""
    if title and title.strip() and title.strip().lower() not in ("untitled", "untitled quiz", "untitled discussion"):
        return []
    return [Issue(
        f"{_WCAG_NS}:2.4.2",
        "info",
        f"This {kind} has no descriptive title (WCAG 2.4.2 Page Titled).",
        f"{kind} title",
        False,
        _WCAG_FIX_HINTS["2.4.2"],
    )]


# ═══════════════════════════════════════════════════════════════════════════
# CHECK FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════

def check_page(html: str, context: dict = None) -> list:
    """Run preflight checks on a single page's HTML content.

    Args:
        html: Raw HTML content of the page (not wrapped in Canvas shell)
        context: Optional dict with keys:
            page_type: str — "overview", "lesson", "knowledge-check", etc.
            module_number: int
            objectives: list of objective text strings
            clos: list of CLO text strings
            slug: str — page slug

    Returns:
        List of Issue objects.
    """
    if not html or not html.strip():
        return [Issue("22.1", "warning", "Page has no content.", "entire page",
                       False, "Add content to this page before publishing.")]

    ctx = context or {}
    issues = []

    # ── 1. Heading hierarchy (Standard 22.1) ──────────────────────────────
    hp = _HeadingParser()
    try:
        hp.feed(html)
    except (ValueError, AssertionError):
        pass

    headings = hp.headings
    if headings:
        # Check for H1 in body (Canvas pages shouldn't have H1; Canvas uses it for title)
        h1s = [h for h in headings if h[0] == 1]
        if h1s:
            for level, line, text in h1s:
                issues.append(Issue(
                    "22.1", "warning",
                    f"H1 found in page body — Canvas uses H1 for the page title. Use H2 instead.",
                    f"line {line}: \"{text[:50]}\"",
                    True, "Change <h1> to <h2>."
                ))

        # Check for skipped levels
        for i in range(1, len(headings)):
            prev_level = headings[i - 1][0]
            curr_level = headings[i][0]
            if curr_level > prev_level + 1:
                issues.append(Issue(
                    "22.1", "error",
                    f"Heading level skipped: H{prev_level} jumps to H{curr_level}.",
                    f"line {headings[i][1]}: \"{headings[i][2][:50]}\"",
                    True,
                    f"Change <h{curr_level}> to <h{prev_level + 1}>."
                ))

    # ── 2. Image alt text (Standard 22.2 / FRAME) ────────────────────────
    ip = _ImgParser()
    try:
        ip.feed(html)
    except (ValueError, AssertionError):
        pass

    for idx, img in enumerate(ip.images):
        if img.get("role") == "presentation":
            continue  # Decorative, skip
        alt_val = img.get("alt")
        # Decorative markers: alt="" (WCAG), alt=" " (Canvas editor), alt="None" (legacy template artifact)
        is_decorative = alt_val is not None and (alt_val.strip() == "" or alt_val.strip().lower() == "none")
        if alt_val is None:
            src_short = img.get("src", "?")[:40]
            issues.append(Issue(
                "22.2", "error",
                f"Image missing alt text.",
                f"line {img['line']}: {src_short}",
                False,  # Never auto-generate alt text
                "Add descriptive alt text, or alt=\"\" if decorative."
            ))
        elif is_decorative:
            # Decorative image. Fine, but flag if it looks like a content image
            src = img.get("src", "").lower()
            if not any(kw in src for kw in ["icon", "spacer", "bullet", "divider",
                                             "decoration", "background"]):
                issues.append(Issue(
                    "22.2", "info",
                    "Image has empty alt text (marked decorative). Verify this isn't a content image.",
                    f"line {img['line']}: {img.get('src', '?')[:40]}",
                    False, "If this conveys meaning, add descriptive alt text."
                ))

    # ── 2a-bis. Page Image Staging Gate: flag images embedded as live Canvas
    #    file URLs instead of asucb-staged-file:// tokens. A page authored with
    #    a direct /files/{id} URL was uploaded straight to Canvas, bypassing the
    #    local-first staged-file preview. Info-level: a re-staged page whose
    #    images were already live legitimately carries /files/ URLs too.
    _CANVAS_FILE_SRC_RE = re.compile(r"/files/\d+")
    for img in ip.images:
        src = img.get("src", "") or ""
        if _CANVAS_FILE_SRC_RE.search(src):
            issues.append(Issue(
                "22.2", "info",
                "Image embeds a live Canvas file URL directly. For page images, "
                "stage the bytes as an asucb-staged-file:// token so they preview "
                "locally before push (Page Image Staging Gate). Ignore if this "
                "image was already live on the page.",
                f"line {img.get('line', '?')}: {src[:48]}",
                False,
                "Use staging_manager --type files + the asucb-staged-file://{key} "
                "token instead of uploading the image and embedding /files/{id}/preview.",
            ))

    # ── 2b. List A image checks (a11y.wcag:1.1.1 — heuristic only, no vision) ──
    # These run on every stage — no image download or vision pass required.
    _FILENAME_ALT_RE = re.compile(
        r"^\s*[\w .-]+\.(png|jpe?g|gif|svg|webp|bmp)\s*$", re.IGNORECASE
    )
    _LATEX_ALT_RE = re.compile(r"^\s*LaTeX:", re.IGNORECASE)
    for idx, img in enumerate(ip.images):
        src = img.get("src", "")
        alt_val = img.get("alt")  # None = absent (already caught above), str otherwise
        role = (img.get("role") or "").lower()
        line = img.get("line", "?")
        src_short = src[:40]

        # (i) Missing alt — already emitted as error above under Standard 22.2;
        #     re-emit as warning under a11y.wcag:1.1.1 for criterion tracking.
        if alt_val is None:
            issues.append(Issue(
                "a11y.wcag:1.1.1", "warning",
                "Image missing alt attribute — cannot determine accessibility.",
                f"line {line}: {src_short}",
                False,
                "Add descriptive alt text, or alt=\"\" + role=\"presentation\" if decorative.",
            ))

        # (ii) Filename-like alt text
        elif _FILENAME_ALT_RE.match(alt_val):
            issues.append(Issue(
                "a11y.wcag:1.1.1", "warning",
                f'Image alt text looks like a filename: "{alt_val}".',
                f"line {line}: {src_short}",
                False,
                "Replace with a descriptive alt text that conveys what the image shows.",
            ))

        # (iii) Raw LaTeX: alt text
        elif _LATEX_ALT_RE.match(alt_val):
            issues.append(Issue(
                "a11y.wcag:1.1.1", "info",
                "Equation image has raw LaTeX source as alt text — not human-readable.",
                f"line {line}: {src_short}",
                False,
                "Provide a human-readable mathematical description as alt text.",
            ))

        # (iv) Suspicious decorative: role=presentation/alt="" but non-trivial
        #      dimensions suggest it might be content
        elif alt_val == "" and role in ("presentation", "none"):
            # Only flag if image doesn't look like a known decorative pattern
            src_lower = src.lower()
            if not any(kw in src_lower for kw in [
                "icon", "spacer", "bullet", "divider", "decoration", "background"
            ]):
                issues.append(Issue(
                    "a11y.wcag:1.1.1", "info",
                    "Image marked decorative (alt=\"\"/role) — verify it conveys no information.",
                    f"line {line}: {src_short}",
                    False,
                    "If this image conveys meaning, add descriptive alt text.",
                ))

    # ── 3. Link text quality (Standard 22.2) ─────────────────────────────
    lp = _LinkParser()
    try:
        lp.feed(html)
    except (ValueError, AssertionError):
        pass

    for link in lp.links:
        text = link.get("text", "").strip()
        href = link.get("href", "")
        if not text and href:
            issues.append(Issue(
                "22.2", "error",
                "Link has no visible text — inaccessible to screen readers.",
                f"line {link['line']}: href=\"{href[:40]}\"",
                False, "Add descriptive link text."
            ))
        elif text and _LinkParser.BAD_TEXT.match(text):
            issues.append(Issue(
                "22.2", "warning",
                f"Non-descriptive link text: \"{text}\".",
                f"line {link['line']}",
                False,
                "Replace with text describing the link destination."
            ))

    # ── 4. Video transcript/caption placeholder (Standards 16, 18, 22) ────
    vp = _VideoEmbedParser()
    try:
        vp.feed(html)
    except (ValueError, AssertionError):
        pass

    if vp.video_embeds and not vp.has_transcript_placeholder():
        for embed in vp.video_embeds:
            issues.append(Issue(
                "16.2", "warning",
                "Video embed found but no transcript or caption reference on this page.",
                f"line {embed['line']}: {embed['type']} ({embed['src'][:40]})",
                False,
                "Add a transcript section below the video or link to captions."
            ))

    # ── 5. Placeholder text detection ────────────────────────────────────
    plain_text = _strip_html(html)
    placeholders = _find_placeholders(html)  # Search raw HTML to catch attrs too
    placeholders += _find_placeholders(plain_text)

    # Deduplicate by match text
    seen = set()
    for match_text, pos in placeholders:
        if match_text not in seen:
            seen.add(match_text)
            issues.append(Issue(
                "04.5", "error",
                f"Placeholder text found: {match_text}",
                f"near position {pos}",
                False,
                "Replace placeholder with actual content before publishing."
            ))

    # ── 6. Objective measurability (if page_type is overview) ─────────────
    page_type = ctx.get("page_type", "").lower()
    if page_type == "overview" and ctx.get("objectives"):
        for obj_text in ctx["objectives"]:
            measurable, verb = is_measurable(obj_text)
            if not measurable:
                issues.append(Issue(
                    "02.1", "error",
                    f"Objective uses unmeasurable verb \"{verb}\".",
                    f"objective: \"{obj_text[:60]}\"",
                    False,
                    "Replace with a measurable Bloom's verb (e.g., Analyze, Evaluate, Create)."
                ))

    # ── 7. Table accessibility (Standard 22) ─────────────────────────────
    issues.extend(_check_tables(html))
    issues.extend(check_table_header_contrast(html))

    # ── 7b. Style: no em/en dashes ───────────────────────────────────────
    issues.extend(check_no_em_dash(html))

    # ── 7b2. Style: no font-weight in inline styles (Canvas strips it) ───
    issues.extend(check_inline_font_weight(html))

    # ── 7c. Style: anti-slop phrases and structures (advisory) ───────────
    issues.extend(check_slop(html, location_label=ctx.get("slug", "")))

    # ── 7d. Style: APA title-case headings (h1-h4 + summary) ────────────
    issues.extend(check_title_case(html))

    # ── 8. WCAG 2.1 A+AA static subset (shared parser, PR2) ──────────────
    issues.extend(_wcag_issues(html, fragment=True))
    # 2.4.2 sourced from page metadata, not body. Skipped when this call is a
    # delegated body scan from check_assessment/check_discussion (they own the
    # object title check themselves).
    if not ctx.get("_skip_title"):
        issues.extend(_wcag_title_issue(ctx.get("title") or ctx.get("slug", ""), "page"))

    return issues


def _check_tables(html: str) -> list:
    """Check tables for accessibility: <th> elements, scope attributes."""
    issues_list = []
    # Simple regex check for tables missing <th>
    table_pattern = re.compile(r"<table[^>]*>(.*?)</table>", re.DOTALL | re.I)
    for i, match in enumerate(table_pattern.finditer(html)):
        table_html = match.group(1)
        if "<th" not in table_html.lower():
            issues_list.append(Issue(
                "22.2", "warning",
                f"Table {i + 1} has no header cells (<th>).",
                f"table {i + 1}",
                True,
                "Add <th scope='col'> or <th scope='row'> to header cells."
            ))
        elif 'scope=' not in table_html.lower():
            issues_list.append(Issue(
                "22.2", "info",
                f"Table {i + 1} has headers but no scope attribute.",
                f"table {i + 1}",
                True,
                "Add scope='col' or scope='row' to <th> elements."
            ))
    return issues_list


# Matches a <tr ...> open tag whose attributes include an inline
# style with a `background`/`background-color` declaration.
_TR_WITH_BG = re.compile(
    r"<tr\b[^>]*\bstyle\s*=\s*([\"'])(?P<style>.*?)\1[^>]*>",
    re.DOTALL | re.I,
)
# Captures each cell open tag inside the row (group 'tag' = th|td, full open tag in group 0).
_CELL_OPEN = re.compile(r"<(?P<tag>th|td)\b[^>]*>", re.DOTALL | re.I)


def _style_has_background(style: str) -> bool:
    """True if an inline style string declares a (non-empty) background.

    Matches `background:` and `background-color:` but ignores bare token
    presence like `background-image` with no color, and skips empty values.
    """
    for m in re.finditer(r"background(?:-color)?\s*:\s*([^;]+)", style, re.I):
        value = m.group(1).strip()
        if value and value.lower() not in ("none", "transparent", "inherit", "initial", "unset"):
            return True
    return False


def check_table_header_contrast(html: str) -> list:
    """Flag rows that set an inline background but whose cells do not.

    A `<tr style="background:...">` colors the row, but the staging renderer
    (and Canvas LMS) apply a default light-gray fill to bare `<th>`/`<td>`
    cells that paints OVER the row background — white header text on the row
    then fails contrast. The staging renderer now neutralizes this with a CSS
    rule, but Canvas LMS strips `<style>` and may still override the row
    background, so this stays a portability heads-up.

    Severity: warning (non-blocking). criterion_id: table.header.row_bg_without_cell_bg.

    Args:
        html: Raw HTML content (page/assignment/discussion body, quiz description).

    Returns:
        List of Issue objects (one per offending row).
    """
    if not html or "<tr" not in html.lower():
        return []

    issues_list = []
    row_index = 0
    for tr_match in _TR_WITH_BG.finditer(html):
        if not _style_has_background(tr_match.group("style")):
            continue
        row_index += 1

        # Extract this row's inner HTML to inspect its cells.
        row_start = tr_match.end()
        close = re.search(r"</tr\s*>", html[row_start:], re.I)
        row_inner = html[row_start: row_start + close.start()] if close else html[row_start:]

        cells = list(_CELL_OPEN.finditer(row_inner))
        if not cells:
            continue

        # If EVERY cell carries its own inline background, the row is portable.
        def _cell_has_bg(cell_tag: str) -> bool:
            sm = re.search(r'style\s*=\s*(["\'])(.*?)\1', cell_tag, re.DOTALL | re.I)
            return bool(sm) and _style_has_background(sm.group(2))

        if all(_cell_has_bg(c.group(0)) for c in cells):
            continue

        issues_list.append(Issue(
            "table.header.row_bg_without_cell_bg", "warning",
            "A table row sets a background but its cells don't; set the "
            "background on each <th>/<td> inline for portability (Canvas "
            "strips <style> and may override row backgrounds).",
            f"table row {row_index}",
            False,
            "Move the background (and text color) from the <tr> onto each "
            "<th>/<td> as inline styles."
        ))
    return issues_list


# ═══════════════════════════════════════════════════════════════════════════
# STYLE LINT: No em or en dashes
# ═══════════════════════════════════════════════════════════════════════════

# Matches the literal em dash (U+2014), en dash (U+2013), and their HTML
# entity forms. The ban is total: every dash form is flagged everywhere,
# including inside code blocks. Detection is an exact character/entity match,
# so there are no false positives.
_DASH_TOKENS = [
    ("—", "em dash"),
    ("–", "en dash"),
    ("&mdash;", "em dash entity (&mdash;)"),
    ("&ndash;", "en dash entity (&ndash;)"),
]

_DASH_REPORT_CAP = 10

_DASH_FIX_HINT = (
    "Replace with a colon (label separator), comma (clause break), "
    "'to' (numeric range), or hyphen (compound modifier). "
    "See standards/page-design.md."
)


def check_no_em_dash(html: str) -> list:
    """Warn on any em dash, en dash, or their HTML entities in content.

    Generated Canvas content uses no em or en dashes. This lint flags every
    occurrence (literal U+2014 / U+2013 or &mdash; / &ndash; entities) anywhere
    in the body, including code blocks; the ban is total. Detection is an exact
    match, so there are no false positives.

    Severity: warning (non-blocking). criterion_id: style.em_dash.

    To keep reports sane on dash-heavy bodies, individual issues are capped at
    `_DASH_REPORT_CAP`; a single summary Issue notes how many more were found.

    Args:
        html: Raw HTML/text content to scan.

    Returns:
        List of Issue objects with criterion_id 'style.em_dash'.
    """
    if not html:
        return []

    # Collect every occurrence as (position, label, length) so the report is
    # ordered by where the dash appears in the body.
    occurrences = []
    for token, label in _DASH_TOKENS:
        start = 0
        while True:
            idx = html.find(token, start)
            if idx == -1:
                break
            occurrences.append((idx, label, len(token)))
            start = idx + len(token)

    if not occurrences:
        return []

    occurrences.sort(key=lambda t: t[0])

    issues = []
    for pos, label, length in occurrences[:_DASH_REPORT_CAP]:
        snippet = _context_snippet(html, pos, pos + length)
        issues.append(Issue(
            "style.em_dash",
            "warning",
            f"Found {label} in content. Generated content uses no em or en dashes.",
            f"near position {pos}: \"{snippet}\"",
            False,
            _DASH_FIX_HINT,
        ))

    extra = len(occurrences) - _DASH_REPORT_CAP
    if extra > 0:
        issues.append(Issue(
            "style.em_dash",
            "warning",
            f"...and {extra} more em/en dash occurrence(s) in this content.",
            f"{len(occurrences)} total dash occurrences",
            False,
            _DASH_FIX_HINT,
        ))

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# STYLE LINT: No font-weight in inline styles (Canvas strips it)
# ═══════════════════════════════════════════════════════════════════════════

# Matches an inline style attribute (double- or single-quoted). font-weight
# is then searched only within the captured attribute value, so mentions of
# "font-weight" in prose, <strong> text, or code samples are never flagged.
_STYLE_ATTR_RE = re.compile(r"style\s*=\s*(\"[^\"]*\"|'[^']*')", re.IGNORECASE)

_FONT_WEIGHT_FIX_HINT = (
    "Wrap the text in <strong>/<b> instead; on default-bold elements "
    "(h1-h6, th) simply remove the declaration. "
    "See standards/page-design.md (Canvas-Specific Constraints)."
)


def check_inline_font_weight(html: str) -> list:
    """Warn on ``font-weight`` inside inline ``style=""`` attributes.

    canvas.asu.edu strips ``font-weight`` from inline styles on page-body
    writes — every element, every value (verified live 2026-07-10; pinned in
    tests/integration/test_live_html_roundtrip.py::
    test_font_weight_stripped_from_inline_style). A staged body relying on it
    silently loses its bolding when pushed. Detection is scoped to style
    attribute VALUES only — "font-weight" appearing in prose or outside a
    style attribute is not flagged.

    Severity: warning (non-blocking). criterion_id: style.font_weight_inline.

    Args:
        html: Raw HTML content to scan.

    Returns:
        List of Issue objects with criterion_id 'style.font_weight_inline'.
    """
    if not html:
        return []

    issues = []
    for attr_match in _STYLE_ATTR_RE.finditer(html):
        value = attr_match.group(1)[1:-1]  # strip surrounding quotes
        fw_idx = value.lower().find("font-weight")
        if fw_idx == -1:
            continue
        pos = attr_match.start(1) + 1 + fw_idx
        snippet = _context_snippet(html, pos, pos + len("font-weight"))
        issues.append(Issue(
            "style.font_weight_inline",
            "warning",
            "Inline style uses font-weight, which Canvas strips from page "
            "bodies on save — the bolding is silently lost live.",
            f"near position {pos}: \"{snippet}\"",
            False,
            _FONT_WEIGHT_FIX_HINT,
        ))
    return issues


# ═══════════════════════════════════════════════════════════════════════════
# STYLE LINT: Anti-slop phrase and structure detection
#
# Adapted for instructional content from the stop-slop skill by Hardik Pandya
# (https://hvpandya.com), MIT License, (c) 2025 Hardik Pandya. No upstream
# code copied verbatim; ASUCB implements its own phrase lists and matching
# logic tuned for Canvas course copy. See standards/anti-slop.md.
#
# Instructional carve-out: adverbs, Wh- question starters, and second person
# are NOT linted here (false-positives on legitimate pedagogical language).
# ═══════════════════════════════════════════════════════════════════════════

_SLOP_REPORT_CAP = 10

# Each entry: (compiled_regex, fix_hint_category)
# All patterns are case-insensitive and word/boundary anchored as appropriate.

# Throat-clearing openers: literal phrase anchored to word boundaries.
_SLOP_THROAT_CLEARING = [
    (re.compile(r"\bhere's the thing\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bhere's what\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bhere's this\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bhere's that\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bhere's why\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bhere's the problem\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bhere's what i find interesting\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bthe truth is[,\s]", re.I), "throat-clearing opener"),
    (re.compile(r"\bthe uncomfortable truth is\b", re.I), "throat-clearing opener"),
    (re.compile(r"\blet me be clear\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bit turns out\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bmake no mistake\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bi'll say it again\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bi'm going to be honest\b", re.I), "throat-clearing opener"),
    (re.compile(r"\bcan we talk about\b", re.I), "throat-clearing opener"),
]

# Emphasis crutches: hollow intensifiers that add no meaning.
_SLOP_EMPHASIS_CRUTCHES = [
    (re.compile(r"\blet that sink in\b", re.I), "emphasis crutch"),
    # "full stop" intentionally NOT linted: it is British English for the
    # period punctuation mark (grammar / ESL / writing courses use it), and
    # appears literally in physics / driver-ed ("comes to a full stop"). Too
    # many false positives across course domains to flag as slop.
    (re.compile(r"\bthis matters because\b", re.I), "emphasis crutch"),
    (re.compile(r"\bhere's why that matters\b", re.I), "emphasis crutch"),
]

# Business jargon in the slop sense only.
# False-positive guard: "navigate to the page" / "navigate to module" must not
# fire; only the metaphorical "navigate [the] challenges / complexity / ..."
# form is slop. "deep dive" as a standalone noun/verb phrase is the slop form.
_SLOP_JARGON = [
    # "navigate" only when followed by "the challenges", "complexity", etc.
    (re.compile(r"\bnavigate\s+(?:the\s+)?(?:challenges?|complexity|landscape|terrain|world)\b", re.I), "business jargon"),
    (re.compile(r"\bunpack\b(?!\s+your\s+bags)", re.I), "business jargon"),
    (re.compile(r"\blean into\b", re.I), "business jargon"),
    (re.compile(r"\bgame[- ]changer\b", re.I), "business jargon"),
    (re.compile(r"\bdouble down\b", re.I), "business jargon"),
    (re.compile(r"\bdeep dive\b", re.I), "business jargon"),
    (re.compile(r"\bmoving forward\b", re.I), "business jargon"),
    (re.compile(r"\bcircle back\b", re.I), "business jargon"),
    (re.compile(r"\bon the same page\b", re.I), "business jargon"),
]

# Filler phrases: padding that says nothing.
_SLOP_FILLER = [
    (re.compile(r"\bat its core\b", re.I), "filler phrase"),
    # "in today's" only in the zeitgeist-filler sense ("in today's world /
    # economy / landscape"), NOT "in today's lesson / class / reading", which
    # is normal course copy.
    (re.compile(r"\bin today's\s+(?:\w+\s+){0,2}(?:world|landscape|economy|environment|climate|society|market|age|era)\b", re.I), "filler phrase"),
    (re.compile(r"\bit's worth noting\b", re.I), "filler phrase"),
    (re.compile(r"\bat the end of the day\b", re.I), "filler phrase"),
    (re.compile(r"\bwhen it comes to\b", re.I), "filler phrase"),
    (re.compile(r"\bin a world where\b", re.I), "filler phrase"),
    (re.compile(r"\bthe reality is\b", re.I), "filler phrase"),
]

# Meta-commentary: narration about the content instead of delivering it.
_SLOP_META = [
    (re.compile(r"\blet me walk you through\b", re.I), "meta-commentary"),
    (re.compile(r"\bin this section[,\s]+we'll\b", re.I), "meta-commentary"),
    (re.compile(r"\bas we'll see\b", re.I), "meta-commentary"),
    (re.compile(r"\bthe rest of this\b.{0,30}\bexplains\b", re.I), "meta-commentary"),
]

# Vague declaratives: announce importance without naming the specific thing.
_SLOP_VAGUE = [
    (re.compile(r"\bthe implications are significant\b", re.I), "vague declarative"),
    (re.compile(r"\bthe stakes are high\b", re.I), "vague declarative"),
    (re.compile(r"\bthe reasons are structural\b", re.I), "vague declarative"),
    (re.compile(r"\bthe consequences are real\b", re.I), "vague declarative"),
]

# Binary-contrast structures: "not X but Y", "isn't X, it's Y", etc.
# The guard "not available until Monday" must NOT fire; the pattern requires
# a short content word followed by "but/it's" to match the slop form.
_SLOP_BINARY_CONTRAST = [
    # "not just X, but Y" or "not X, but Y" (slop binary contrast)
    re.compile(r"\bnot\s+(?:just\s+)?\w[\w\s]{0,30},?\s+but\b", re.I),
    # "isn't X, it's Y"
    re.compile(r"\bisn't\s+\w[\w\s]{0,20},?\s+it's\b", re.I),
    # "The answer isn't ... It's ..." or "The question isn't ... It's ..."
    re.compile(r"\bthe\s+(?:answer|question)\s+isn't\b", re.I),
]

# Combined list for iteration: (pattern, category) tuples.
_ALL_SLOP_PATTERNS = (
    _SLOP_THROAT_CLEARING
    + _SLOP_EMPHASIS_CRUTCHES
    + _SLOP_JARGON
    + _SLOP_FILLER
    + _SLOP_META
    + _SLOP_VAGUE
    + [(p, "binary-contrast structure") for p in _SLOP_BINARY_CONTRAST]
)

_SLOP_FIX_HINTS = {
    "throat-clearing opener": "Cut the opener; state the point directly.",
    "emphasis crutch": "Delete this phrase; the surrounding content carries the weight.",
    "business jargon": "Replace with plain language (e.g., 'handle' for 'navigate the challenges').",
    "filler phrase": "Delete without replacement; nothing is lost.",
    "meta-commentary": "Remove the self-referential narration; let the content move.",
    "vague declarative": "Name the specific implication, stake, or consequence.",
    "binary-contrast structure": "State the positive claim directly; drop the negation.",
}


def check_slop(content: str, location_label: str = "") -> list:
    """Warn on AI-slop phrases and structures in plain text or HTML content.

    Advisory only (severity='info', fixable=False). Strips HTML before
    scanning so tags do not produce false matches. Deduplicates by matched
    phrase; caps the report at _SLOP_REPORT_CAP individual issues plus one
    summary issue when more are found.

    Instructional carve-out: adverbs, Wh- question starters, and second
    person are NOT linted (see standards/anti-slop.md).

    False-positive guard: business-jargon entries match only the slop sense
    ("navigate the challenges", "deep dive") not generic uses
    ("navigate to the page", "not available until Monday").

    Args:
        content: HTML or plain text string to scan.
        location_label: Optional label for the location field in Issues.

    Returns:
        List of Issue objects with criterion_id 'style.slop', severity 'info'.
    """
    if not content:
        return []

    plain = _strip_html(content)
    if not plain.strip():
        return []

    findings = []  # (match_start, matched_phrase, category)
    seen_phrases = set()

    for pattern, category in _ALL_SLOP_PATTERNS:
        for m in pattern.finditer(plain):
            phrase_key = m.group(0).lower()
            if phrase_key in seen_phrases:
                continue
            seen_phrases.add(phrase_key)
            findings.append((m.start(), m.group(0), category))

    if not findings:
        return []

    findings.sort(key=lambda t: t[0])

    issues = []
    for pos, phrase, category in findings[:_SLOP_REPORT_CAP]:
        hint = _SLOP_FIX_HINTS.get(category, "Revise to plain instructional language.")
        loc_parts = []
        if location_label:
            loc_parts.append(location_label)
        loc_parts.append(f"matched \"{phrase}\"")
        issues.append(Issue(
            "style.slop",
            "info",
            f"AI-slop pattern ({category}): \"{phrase}\". {hint}",
            " > ".join(loc_parts),
            False,
            hint,
        ))

    extra = len(findings) - _SLOP_REPORT_CAP
    if extra > 0:
        loc = location_label or "content"
        issues.append(Issue(
            "style.slop",
            "info",
            f"...and {extra} more slop pattern(s) in this content.",
            f"{len(findings)} total slop patterns in {loc}",
            False,
            "Review standards/anti-slop.md for the full phrase list.",
        ))

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# STYLE LINT: APA title-case headings
# ═══════════════════════════════════════════════════════════════════════════

def check_title_case(html: str) -> list:
    """Warn when h1-h4 or summary headings violate APA title-case rules.

    Delegates to ``apa_title_case.lint_html`` which extracts heading text from
    the HTML and runs ``check_title`` against each one. Faculty-verbatim prose
    is untouched -- only heading tags are inspected.

    Severity: warning (non-blocking). criterion_id: style.title_case.
    Severity floor: never 'error'; these are advisory style checks.

    Args:
        html: Raw HTML content string to scan.

    Returns:
        List of Issue objects with criterion_id 'style.title_case'.
    """
    if not html or not html.strip():
        return []

    try:
        from apa_title_case import lint_html as _lint_html
    except ImportError:
        # apa_title_case not available; skip silently to avoid breaking callers
        return []

    violations = _lint_html(html)
    if not violations:
        return []

    issues = []
    for tag, text, sub_issues in violations:
        detail = "; ".join(sub_issues)
        issues.append(Issue(
            "style.title_case",
            "warning",
            f"Heading <{tag}> violates APA title-case: {detail}.",
            f"<{tag}> \"{text[:60]}\"",
            False,
            "Apply APA title-case: capitalize words of 4+ letters; lowercase "
            "articles, conjunctions, and short prepositions mid-title. "
            "See standards/page-design.md.",
        ))

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# ASSESSMENT CHECKS
# ═══════════════════════════════════════════════════════════════════════════

def check_assessment(assessment_data: dict, context: dict = None) -> list:
    """Run preflight checks on an assignment/assessment.

    Args:
        assessment_data: dict with keys from Canvas API:
            name, description (HTML), points_possible, submission_types,
            rubric (list of criteria), due_at, has_rubric_association
        context: Optional dict:
            module_number, objectives (list of text), clos, bloom_target (str)

    Returns:
        List of Issue objects.
    """
    ctx = context or {}
    issues = []
    name = assessment_data.get("name", "Untitled")

    # ── Rubric check (Standard 08.3) ─────────────────────────────────────
    has_rubric = (assessment_data.get("rubric") or
                  assessment_data.get("has_rubric_association") or
                  assessment_data.get("has_rubric", False))
    if not has_rubric:
        issues.append(Issue(
            "08.3", "error",
            f"Assignment \"{name}\" has no rubric attached.",
            f"assessment: {name}",
            False,
            "Create and attach a rubric with criteria aligned to objectives."
        ))

    # ── Points set (Standard 09) ─────────────────────────────────────────
    points = assessment_data.get("points_possible")
    if points is None or points == 0:
        issues.append(Issue(
            "09.1", "warning",
            f"Assignment \"{name}\" has no points set.",
            f"assessment: {name}",
            False,
            "Set points_possible to reflect the assessment weight."
        ))

    # ── Due date check (Standard 06.6) ───────────────────────────────────
    if not assessment_data.get("due_at"):
        issues.append(Issue(
            "06.6", "warning",
            f"Assignment \"{name}\" has no due date.",
            f"assessment: {name}",
            False,
            "Set a due date before publishing."
        ))

    # ── Bloom's level check (Standard 11) ────────────────────────────────
    bloom_target = ctx.get("bloom_target")
    if bloom_target and assessment_data.get("description"):
        # Check if the description/prompt suggests the right cognitive level
        desc_text = _strip_html(assessment_data["description"])
        verb, level = classify_blooms(desc_text)
        if level != "Unknown" and bloom_target != "Unknown":
            target_rank = BLOOMS_RANK.get(bloom_target, 0)
            actual_rank = BLOOMS_RANK.get(level, 0)
            if actual_rank < target_rank - 1:
                issues.append(Issue(
                    "11.1", "warning",
                    f"Assignment prompt uses \"{verb}\" ({level}) but module targets {bloom_target}.",
                    f"assessment: {name}",
                    False,
                    f"Consider using verbs at the {bloom_target} level or higher."
                ))

    # ── WCAG 2.4.2 (title from object metadata, not body) ────────────────
    issues.extend(_wcag_title_issue(assessment_data.get("name", ""), "assignment"))

    # ── Description accessibility ────────────────────────────────────────
    desc_html = assessment_data.get("description", "")
    if desc_html:
        page_issues = check_page(desc_html, context={**ctx, "_skip_title": True})
        # Re-tag location to include assessment name
        for issue in page_issues:
            issue.location = f"assessment \"{name}\" > {issue.location}"
        issues.extend(page_issues)

    return issues


def check_quiz(quiz_data: dict, context: dict = None) -> list:
    """Run preflight checks on a quiz.

    Args:
        quiz_data: dict with keys from Canvas API:
            title, description, points_possible, allowed_attempts,
            time_limit, shuffle_answers, questions (list)
        context: Optional dict:
            module_number, objectives, bloom_target

    Returns:
        List of Issue objects.
    """
    ctx = context or {}
    issues = []
    title = quiz_data.get("title", "Untitled Quiz")

    # ── Attempt settings (Standard 10) ───────────────────────────────────
    attempts = quiz_data.get("allowed_attempts", -1)
    if attempts == 1:
        issues.append(Issue(
            "10.1", "info",
            f"Quiz \"{title}\" allows only 1 attempt. Consider multiple attempts for formative assessments.",
            f"quiz: {title}",
            False,
            "Set allowed_attempts to 2-3 for knowledge checks."
        ))

    # ── Question feedback check ──────────────────────────────────────────
    questions = quiz_data.get("questions", [])
    missing_feedback = 0
    for i, q in enumerate(questions):
        answers = q.get("answers", [])
        for a in answers:
            if not a.get("comments") and not a.get("comments_html"):
                missing_feedback += 1
                break  # Count once per question

    if missing_feedback > 0 and questions:
        severity = "error" if missing_feedback > len(questions) // 2 else "warning"
        issues.append(Issue(
            "10.2", severity,
            f"{missing_feedback} of {len(questions)} questions missing answer feedback.",
            f"quiz: {title}",
            False,
            "Add feedback explaining why each answer is correct or incorrect."
        ))

    # ── Question variety (Standard 10.1) ─────────────────────────────────
    if questions:
        q_types = set(q.get("question_type", "unknown") for q in questions)
        if len(q_types) == 1 and len(questions) > 3:
            issues.append(Issue(
                "10.1", "info",
                f"All {len(questions)} questions are the same type. Consider mixing question formats.",
                f"quiz: {title}",
                False,
                "Use 2-3 different question types (multiple choice, matching, short answer, etc.)."
            ))

    # ── Due date (Standard 06.6) ─────────────────────────────────────────
    if not quiz_data.get("due_at"):
        issues.append(Issue(
            "06.6", "warning",
            f"Quiz \"{title}\" has no due date.",
            f"quiz: {title}",
            False, "Set a due date before publishing."
        ))

    # ── Bloom's check on questions ───────────────────────────────────────
    bloom_target = ctx.get("bloom_target")
    if bloom_target and questions:
        low_level_count = 0
        for q in questions:
            q_text = q.get("question_text", "") or q.get("question_name", "")
            plain = _strip_html(q_text)
            verb, level = classify_blooms(plain)
            if level in ("Remember", "Understand") and bloom_target in ("Apply", "Analyze", "Evaluate", "Create"):
                low_level_count += 1

        if low_level_count > len(questions) // 2:
            issues.append(Issue(
                "11.4", "warning",
                f"Most questions test lower-order thinking but module targets {bloom_target}.",
                f"quiz: {title}",
                False,
                f"Include scenario-based questions at the {bloom_target} level."
            ))

    # ── WCAG 2.1 A+AA on quiz HTML fields (PR2) ──────────────────────────
    issues.extend(_wcag_title_issue(quiz_data.get("title", ""), "quiz"))
    desc_html = quiz_data.get("description", "")
    if desc_html:
        for issue in _wcag_issues(desc_html, fragment=True):
            issue.location = f"quiz \"{title}\" description > {issue.location}"
            issues.append(issue)
    for idx, q in enumerate(questions, start=1):
        if not isinstance(q, dict):
            continue
        q_html = q.get("question_text", "") or ""
        for issue in _wcag_issues(q_html, fragment=True):
            issue.location = f"quiz \"{title}\" Q{idx} > {issue.location}"
            issues.append(issue)

    # ── Anti-slop: description and each question_text (advisory) ─────────
    if desc_html:
        for issue in check_slop(desc_html, location_label=f"quiz \"{title}\" description"):
            issues.append(issue)
    for idx, q in enumerate(questions, start=1):
        if not isinstance(q, dict):
            continue
        q_html = q.get("question_text", "") or ""
        if q_html:
            for issue in check_slop(q_html, location_label=f"quiz \"{title}\" Q{idx}"):
                issues.append(issue)

    # ── APA title-case: quiz description headings ─────────────────────────
    if desc_html:
        for issue in check_title_case(desc_html):
            issue.location = f"quiz \"{title}\" description > {issue.location}"
            issues.append(issue)

    return issues


def check_discussion(discussion_data: dict, context: dict = None) -> list:
    """Run preflight checks on a discussion topic.

    Args:
        discussion_data: dict with keys from Canvas API:
            title, message (HTML), assignment (nested), require_initial_post
        context: Optional dict:
            module_number, objectives, bloom_target

    Returns:
        List of Issue objects.
    """
    ctx = context or {}
    issues = []
    title = discussion_data.get("title", "Untitled Discussion")

    # ── Post-first setting (Standard 17) ─────────────────────────────────
    if not discussion_data.get("require_initial_post", False):
        issues.append(Issue(
            "17.1", "warning",
            f"Discussion \"{title}\" doesn't require initial post before viewing replies.",
            f"discussion: {title}",
            False,
            "Enable 'require initial post' so students share original ideas first."
        ))

    # ── Rubric check (graded discussions) ────────────────────────────────
    assignment = discussion_data.get("assignment")
    if assignment:
        has_rubric = (assignment.get("rubric") or
                      assignment.get("has_rubric_association", False))
        if not has_rubric:
            issues.append(Issue(
                "08.3", "error",
                f"Graded discussion \"{title}\" has no rubric.",
                f"discussion: {title}",
                False,
                "Attach a rubric with criteria for initial post quality and peer engagement."
            ))

        # Points check
        points = assignment.get("points_possible")
        if points is None or points == 0:
            issues.append(Issue(
                "09.1", "warning",
                f"Graded discussion \"{title}\" has no points set.",
                f"discussion: {title}",
                False, "Set points_possible."
            ))

    # ── Prompt quality heuristic (Standards 08, 17) ──────────────────────
    message = discussion_data.get("message", "")
    if message:
        plain = _strip_html(message)
        words = plain.split()

        # Too short? Probably placeholder
        if len(words) < 20:
            issues.append(Issue(
                "17.1", "warning",
                f"Discussion prompt is very short ({len(words)} words). May be a placeholder.",
                f"discussion: {title}",
                False,
                "Expand the prompt with scenario context and clear expectations."
            ))

        # Check for recall-only prompt
        recall_signals = ["what is", "what are", "define ", "list the", "name the"]
        plain_lower = plain.lower()
        if any(sig in plain_lower for sig in recall_signals):
            bloom_target = ctx.get("bloom_target", "")
            if bloom_target in ("Apply", "Analyze", "Evaluate", "Create"):
                issues.append(Issue(
                    "11.1", "info",
                    f"Discussion prompt may be recall-level but module targets {bloom_target}.",
                    f"discussion: {title}",
                    False,
                    "Consider rephrasing to require analysis, evaluation, or application."
                ))

    # ── WCAG 2.4.2 (title from object metadata, not body) ────────────────
    issues.extend(_wcag_title_issue(discussion_data.get("title", ""), "discussion"))

    # ── Description accessibility ────────────────────────────────────────
    if message:
        page_issues = check_page(message, context={**ctx, "_skip_title": True})
        for issue in page_issues:
            issue.location = f"discussion \"{title}\" > {issue.location}"
        issues.extend(page_issues)

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# MODULE-LEVEL CHECK (ORCHESTRATOR)
# ═══════════════════════════════════════════════════════════════════════════

def check_module(module_data: dict, context: dict = None) -> list:
    """Run preflight checks on an entire module's contents.

    Args:
        module_data: dict with keys:
            name: str — module name
            number: int — module number
            items: list of module items (dicts with type, title, etc.)
            pages: list of page dicts (title, body HTML)
            assignments: list of assignment dicts
            quizzes: list of quiz dicts
            discussions: list of discussion dicts
        context: Optional dict:
            objectives: list of objective texts for this module
            clos: list of CLO texts
            bloom_target: expected Bloom's level for this module

    Returns:
        List of Issue objects.
    """
    ctx = context or {}
    issues = []
    mod_name = module_data.get("name", "Unknown Module")
    mod_num = module_data.get("number", 0)

    # ── Module overview page present (Standard 04.5) ─────────────────────
    pages = module_data.get("pages", [])
    has_overview = any(
        "overview" in (p.get("title", "").lower() or p.get("slug", "").lower())
        for p in pages
    )
    if not has_overview and mod_num > 0:
        issues.append(Issue(
            "04.5", "warning",
            f"Module {mod_num} has no overview page.",
            f"module: {mod_name}",
            False,
            "Add a module overview page with objectives, topics, and time estimates."
        ))

    # ── Objectives listed (Standard 02) ──────────────────────────────────
    objectives = ctx.get("objectives", [])
    if not objectives and mod_num > 0:
        issues.append(Issue(
            "02.1", "warning",
            f"No learning objectives found for Module {mod_num}.",
            f"module: {mod_name}",
            False,
            "Define 2-5 measurable learning objectives in course-config.json."
        ))
    else:
        for obj in objectives:
            measurable, verb = is_measurable(obj)
            if not measurable:
                issues.append(Issue(
                    "02.1", "error",
                    f"Objective uses unmeasurable verb \"{verb}\".",
                    f"module {mod_num} objective: \"{obj[:60]}\"",
                    False,
                    "Replace with a measurable Bloom's verb."
                ))

    # ── Assessment variety (Standard 10.1) ───────────────────────────────
    assignments = module_data.get("assignments", [])
    quizzes = module_data.get("quizzes", [])
    discussions = module_data.get("discussions", [])
    assessment_types = set()
    if assignments:
        assessment_types.add("assignment")
    if quizzes:
        assessment_types.add("quiz")
    if discussions:
        assessment_types.add("discussion")

    if len(assessment_types) < 2 and mod_num > 0:
        issues.append(Issue(
            "10.1", "info",
            f"Module {mod_num} has only {len(assessment_types)} assessment type(s). Consider adding variety.",
            f"module: {mod_name}",
            False,
            "A typical module includes a quiz, a discussion, and an assignment."
        ))

    # ── Run individual checks on each content piece ──────────────────────
    for page in pages:
        page_type = _infer_page_type(page.get("title", ""), page.get("slug", ""))
        page_ctx = {**ctx, "page_type": page_type, "module_number": mod_num}
        body = page.get("body") or page.get("html") or ""
        if body:
            page_issues = check_page(body, context=page_ctx)
            for issue in page_issues:
                issue.location = f"page \"{page.get('title', '?')}\" > {issue.location}"
            issues.extend(page_issues)

    for assignment in assignments:
        a_issues = check_assessment(assignment, context=ctx)
        issues.extend(a_issues)

    for quiz in quizzes:
        q_issues = check_quiz(quiz, context=ctx)
        issues.extend(q_issues)

    for discussion in discussions:
        d_issues = check_discussion(discussion, context=ctx)
        issues.extend(d_issues)

    return issues


def _infer_page_type(title: str, slug: str = "") -> str:
    """Infer the page type from title or slug."""
    combined = (title + " " + slug).lower()
    if "overview" in combined:
        return "overview"
    elif "lesson" in combined or "introduction" in combined:
        return "lesson"
    elif "knowledge" in combined or "quiz" in combined:
        return "knowledge-check"
    elif "practice" in combined:
        return "guided-practice"
    elif "artifact" in combined or "assignment" in combined:
        return "assignment"
    elif "conclusion" in combined or "reflection" in combined:
        return "conclusion"
    elif "discussion" in combined:
        return "discussion"
    elif "prepare" in combined:
        return "prepare-to-learn"
    elif "resource" in combined:
        return "resources"
    return "page"


# ═══════════════════════════════════════════════════════════════════════════
# SUMMARY HELPERS
# ═══════════════════════════════════════════════════════════════════════════

def summarize_issues(issues: list) -> dict:
    """Produce a summary dict from a list of Issues.

    Returns:
        {
            "total": int,
            "errors": int,
            "warnings": int,
            "info": int,
            "fixable": int,
            "by_criterion": {criterion_id: count},
            "issues": [issue.to_dict() for each]
        }
    """
    errors = sum(1 for i in issues if i.severity == "error")
    warnings = sum(1 for i in issues if i.severity == "warning")
    info = sum(1 for i in issues if i.severity == "info")
    fixable = sum(1 for i in issues if i.fixable)

    by_criterion = {}
    for i in issues:
        by_criterion[i.criterion_id] = by_criterion.get(i.criterion_id, 0) + 1

    return {
        "total": len(issues),
        "errors": errors,
        "warnings": warnings,
        "info": info,
        "fixable": fixable,
        "by_criterion": by_criterion,
        "issues": [i.to_dict() for i in issues],
    }


def format_scorecard(module_name: str, issues: list) -> str:
    """Format issues as a compact terminal scorecard.

    Returns a multi-line string for display in conversation.
    """
    summary = summarize_issues(issues)
    lines = []
    lines.append(f"{module_name} — Quick Check")
    lines.append("━" * min(60, len(lines[0]) + 10))

    if not issues:
        lines.append("✅ All checks passed — no issues found.")
        return "\n".join(lines)

    # Group by criterion, show each
    seen_criteria = set()
    for issue in sorted(issues, key=lambda i: (
        {"error": 0, "warning": 1, "info": 2}.get(i.severity, 3),
        i.criterion_id)):

        icon = {"error": "🔴", "warning": "⚠️", "info": "ℹ️"}.get(issue.severity, "?")
        lines.append(f"{icon} {issue.message}")
        if issue.fix_hint:
            lines.append(f"   ↳ {issue.fix_hint}")

    lines.append("━" * min(60, len(lines[0]) + 10))
    parts = []
    if summary["errors"]:
        parts.append(f"{summary['errors']} error(s)")
    if summary["warnings"]:
        parts.append(f"{summary['warnings']} warning(s)")
    if summary["info"]:
        parts.append(f"{summary['info']} info")
    if summary["fixable"]:
        parts.append(f"{summary['fixable']} auto-fixable")
    lines.append(", ".join(parts) if parts else "No issues.")

    return "\n".join(lines)


# ═══════════════════════════════════════════════════════════════════════════
# TIER 1 / TIER 2 ENTRY POINTS (ASUCB staging integration)
# ═══════════════════════════════════════════════════════════════════════════

# Tier 1 checks only need the staged payload — run automatically on stage().
# Tier 2 checks need Canvas/config context — run on demand by Claude.

# Page-level Tier 1 checks (heading, alt text, links, tables, video, placeholders,
# Bloom jargon, replace tone, badge style, anti-slop):
TIER1_PAGE_CHECKS = {"22.1", "22.2", "16.2", "04.5",
                     "bloom_jargon", "replace_tone_directive", "badge_style_mismatch",
                     "style.slop", "style.font_weight_inline"}

# Assessment Tier 2 checks (rubric, points, due date, Bloom's):
TIER2_CHECKS = {"08.3", "09.1", "06.6", "11.1", "11.4", "02.1", "17.1"}


def check_page_tier1(html: str, slug: str = "") -> list:
    """Tier 1: payload-only checks. Called automatically on stage().

    Runs heading hierarchy, alt text, link text, table a11y, video transcripts,
    placeholder detection, Bloom jargon lint, and badge style lint.
    Does NOT need Canvas API or course-config context.

    Also emits advisory brand.* findings (font, italics, contrast pairs,
    off-palette) as Issue objects — written to .issues.json sidecar.
    Advisory only (D4 sidecar-only decision); never blocks push.
    """
    page_type = _infer_page_type("", slug) if slug else ""
    # Run check_page WITHOUT objectives context (skips objective measurability)
    issues = check_page(html, context={"page_type": page_type, "slug": slug})
    # Filter to Tier 1 criteria only. WCAG 2.1 findings (a11y.wcag:*) surface
    # on stage too; they are warning/info so they inform without blocking.
    tier1 = [i for i in issues
             if i.criterion_id in TIER1_PAGE_CHECKS or i.criterion_id.startswith(_WCAG_NS + ":")]
    # Feature A: Bloom jargon lint
    tier1.extend(check_bloom_jargon(html))
    # Feature B: Collaborative tone (page staging — checks for embedded REPLACE badges)
    tier1.extend(check_replace_tone(html))
    # Feature C: Badge style lint
    tier1.extend(check_badge_style(html))
    # Brand checks — advisory sidecar (D4)
    tier1.extend(_check_brand_advisory(html, slug))
    return tier1


def _check_brand_advisory(html: str, slug: str = "") -> list:
    """Emit brand.* Issue objects from check_asu_brand for the .issues.json sidecar.

    Advisory only — severity floor is 'warning' for failures, 'info' for off-palette.
    Never raises; returns [] on any error so Tier 1 continues.
    """
    if not _BRAND_CHECK_AVAILABLE:
        return []
    import tempfile
    from pathlib import Path as _Path

    issues_out = []
    try:
        with tempfile.NamedTemporaryFile(
            suffix=".html", mode="w", encoding="utf-8", delete=False
        ) as tmp:
            tmp.write(html)
            tmp_path = _Path(tmp.name)

        brand_failures, brand_warnings = _brand_check_file(tmp_path)
        tmp_path.unlink(missing_ok=True)

        for finding in brand_failures:
            issues_out.append(Issue(
                criterion_id=f"brand.{finding.get('code', 'UNKNOWN').lower()}",
                severity="warning",  # Advisory: brand failures are warnings in sidecar
                message=finding.get("message", "Brand violation"),
                location=slug or finding.get("path", ""),
                fixable=False,
                fix_hint="See standards/page-design.md and standards/anti-slop.md for ASU brand rules.",
            ))
        for finding in brand_warnings:
            issues_out.append(Issue(
                criterion_id=f"brand.{finding.get('code', 'UNKNOWN').lower()}",
                severity="info",
                message=finding.get("message", "Brand advisory"),
                location=slug or finding.get("path", ""),
                fixable=False,
                fix_hint="See standards/page-design.md for ASU brand palette.",
            ))
    except Exception as exc:  # noqa: BLE001
        _log.debug("Brand advisory check failed (non-fatal): %s", exc)

    return issues_out


def check_quiz_tier1(quiz_data: dict) -> list:
    """Tier 1: quiz payload-only checks. Called automatically on stage().

    Runs question feedback presence, identical-feedback warnings, question type variety,
    Bloom jargon lint, collaborative tone lint, badge style lint, and the
    inline font-weight lint (Canvas strips font-weight from body HTML).
    Does NOT check due dates, Bloom's alignment, or other context-dependent items.
    """
    issues = check_quiz(quiz_data)
    issues.extend(check_identical_feedback(quiz_data))
    # Filter to Tier 1 (feedback and variety only — no due date, no Bloom's)
    tier1 = [i for i in issues if i.criterion_id in ("10.1", "10.2", "identical_feedback")]
    # Inline font-weight lint on the quiz description body (Canvas-sanitized)
    desc_html = quiz_data.get("description", "") if isinstance(quiz_data, dict) else ""
    tier1.extend(check_inline_font_weight(desc_html))
    # Feature A/B/C: scan each question's question_text
    questions = quiz_data.get("questions", []) if isinstance(quiz_data, dict) else []
    for idx, question in enumerate(questions, start=1):
        if not isinstance(question, dict):
            continue
        q_text = question.get("question_text", "") or ""
        q_id = question.get("question_name") or str(idx)
        decision_yaml = {"tag": question.get("_decision_tag", "")} if question.get("_decision_tag") else None
        tier1.extend(check_bloom_jargon(q_text, question_id=q_id))
        tier1.extend(check_replace_tone(q_text, decision_yaml=decision_yaml))
        tier1.extend(check_badge_style(q_text))
        tier1.extend(check_inline_font_weight(q_text))
    return tier1


def _feedback_text(value):
    return re.sub(r"\s+", " ", _strip_html(value)).strip()


def check_identical_feedback(quiz_data: dict) -> list:
    """Warn when quiz question feedback channels repeat identical text."""
    issues = []
    questions = quiz_data.get("questions", []) if isinstance(quiz_data, dict) else []
    if not isinstance(questions, list):
        return issues
    pairs = (
        ("correct_comments", "incorrect_comments"),
        ("correct_comments", "neutral_comments"),
        ("incorrect_comments", "neutral_comments"),
    )
    def _read(q, key):
        # Accept `_html` fallback so identical-feedback detection works
        # regardless of whether the staged quiz uses plain or `_html` field
        # names (per fetch_canvas_to_stage.py canonical: plain).
        return q.get(key) or q.get(f"{key}_html", "")

    for index, question in enumerate(questions, start=1):
        if not isinstance(question, dict):
            continue
        label = question.get("question_name") or f"Question {index}"
        for left_key, right_key in pairs:
            left = _feedback_text(_read(question, left_key))
            right = _feedback_text(_read(question, right_key))
            if left and right and left == right:
                issues.append(Issue(
                    "identical_feedback",
                    "warning",
                    (
                        f"Question \"{label}\" uses identical {left_key} "
                        f"and {right_key} feedback."
                    ),
                    f"question {index}: {label}",
                    False,
                    "Use distinct feedback for correct, incorrect, and neutral outcomes."
                ))
    return issues


def check_quiz_details_consistency(quiz: dict) -> list:
    """Warn on quiz Details settings that are self-inconsistent.

    All findings are non-blocking warnings. See PR Spec 1.

    Warnings:
      - scoring_policy="keep_highest" or "take_average" with allowed_attempts==1
        → redundant_scoring_policy (the policy has no effect with a single attempt)
      - published=True with points_possible==0 → published_no_points
      - time_limit set with quiz_type="practice_quiz" → time_limit_on_practice
    """
    issues: list = []
    if not isinstance(quiz, dict):
        return issues

    scoring_policy = quiz.get("scoring_policy")
    allowed_attempts = quiz.get("allowed_attempts")
    quiz_type = quiz.get("quiz_type")
    points_possible = quiz.get("points_possible")
    time_limit = quiz.get("time_limit")
    published = quiz.get("published")

    if allowed_attempts == 1 and scoring_policy == "keep_highest":
        issues.append(Issue(
            criterion_id="redundant_scoring_policy",
            severity="warning",
            message='scoring_policy "keep_highest" has no effect when allowed_attempts is 1.',
            location="quiz.scoring_policy",
            fixable=False,
            fix_hint="Either increase allowed_attempts or leave scoring_policy at the default.",
        ))
    if allowed_attempts == 1 and scoring_policy == "take_average":
        issues.append(Issue(
            criterion_id="redundant_scoring_policy",
            severity="warning",
            message='scoring_policy "take_average" has no effect when allowed_attempts is 1.',
            location="quiz.scoring_policy",
            fixable=False,
            fix_hint="Either increase allowed_attempts or leave scoring_policy at the default.",
        ))
    if published is True and (points_possible == 0 or points_possible == 0.0):
        issues.append(Issue(
            criterion_id="published_no_points",
            severity="warning",
            message="Quiz is published but has 0 points_possible.",
            location="quiz.points_possible",
            fixable=False,
            fix_hint="Set points_possible > 0, or unpublish until questions are added.",
        ))
    if time_limit is not None and isinstance(time_limit, (int, float)) and time_limit >= 1 and quiz_type == "practice_quiz":
        issues.append(Issue(
            criterion_id="time_limit_on_practice",
            severity="warning",
            message="Practice quizzes typically should not be time-limited; learners should be able to revisit.",
            location="quiz.time_limit",
            fixable=False,
            fix_hint="Clear time_limit, or change quiz_type to assignment.",
        ))
    return issues


def check_page_tier2(html: str, context: dict) -> list:
    """Tier 2: context-dependent checks. Called on demand with Canvas/config data.

    Runs objective measurability and any checks that need course-config context.
    Context should include: objectives, clos, bloom_target, page_type, module_number.
    """
    issues = check_page(html, context=context)
    return [i for i in issues if i.criterion_id in TIER2_CHECKS]


def check_full(html: str, context: dict) -> list:
    """Both tiers combined. Called when full context is available."""
    return check_page(html, context=context)


# ═══════════════════════════════════════════════════════════════════════════
# Mockup lane-discipline lints (locked 2026-05-27 — STP530 iteration)
# ═══════════════════════════════════════════════════════════════════════════
#
# These lints enforce the ID-vs-SME lane convention documented in
# `skills/course-mockup/SKILL.md` §Mockup Quiz Layout Convention. They are
# advisory only (warning severity, non-blocking) — they exist so that future
# mockup work catches the same drift patterns Brent surfaced during the
# STP530 M1/M2/M3 review cycles without re-litigating each one.


# Common stats / methods abbreviations that read as jargon to non-stats
# faculty in faculty-facing prose. Allowed inside <pre> code blocks and
# inside <em> MLO quotes (where the abbreviation may be part of a verbatim
# MLO).
_LANE_ABBREVIATIONS = ("CI", "MR", "df")

# Compound-noun pattern: three-or-more-word hyphenated noun followed by
# "item" (e.g., "confidence-interval-interpretation item",
# "regression-output-reading item"). These are ID shorthand that doesn't
# travel to faculty; the convention is to describe the response format
# in plain English instead.
_COMPOUND_NOUN_RE = re.compile(r"\b\w+-\w+-\w+ item\b", flags=re.IGNORECASE)

# LLM attribution badge: a <strong> tag wrapping "Example Generated by Claude".
# Group 1 captures the <strong> tag's attributes so the maroon-hex check can
# inspect them. Matched against the raw content (attributes intact), not the
# protected-region-stripped scan text.
_ATTRIBUTION_STRONG_RE = re.compile(
    r"<strong\b([^>]*)>\s*Example Generated by Claude",
    flags=re.IGNORECASE,
)


def _strip_protected_regions(html: str) -> str:
    """Blank out <pre>…</pre> and <em>…</em> regions so lane lints skip them.

    Preserves length so location offsets keep matching the source.
    """
    def _blank(match):
        return " " * len(match.group(0))

    cleaned = re.sub(r"<pre\b[^>]*>.*?</pre>", _blank, html,
                     flags=re.IGNORECASE | re.DOTALL)
    cleaned = re.sub(r"<em\b[^>]*>.*?</em>", _blank, cleaned,
                     flags=re.IGNORECASE | re.DOTALL)
    return cleaned


def check_mockup_lane_discipline(content: str) -> list:
    """Flag ID-lane violations in mockup quiz description HTML.

    Catches three patterns that drift the ID out of lane:
      1. Compound nouns ("confidence-interval-interpretation item") — replace
         with a plain response-format description.
      2. Unexplained jargon abbreviations (CI, MR, df) in faculty-facing
         prose — spell them out or scope them to a code block / MLO quote.
      3. Bloom verbs (Remember/Understand/Apply/Analyze/Evaluate/Create) in
         faculty-facing prose — already covered by `check_bloom_jargon` for
         the "<verb> level" form, extended here to bare-verb occurrences.

    All severity=warning; never blocks. Skips <pre> and <em> regions.
    """
    issues = []
    if not content:
        return issues

    scan_text = _strip_protected_regions(content)

    for match in _COMPOUND_NOUN_RE.finditer(scan_text):
        issues.append(Issue(
            criterion_id="mockup.lane.compound_noun",
            severity="warning",
            message=(
                f"Compound-noun jargon: '{match.group(0)}'. Describe the "
                "response format in plain English (e.g., 'a multiple-choice "
                "item that asks students to interpret...')."
            ),
            location=_context_snippet(scan_text, match.start(), match.end()),
            fixable=False,
            fix_hint=(
                "Replace the hyphenated noun with a plain response-format "
                "phrase + the verbatim MLO verb."
            ),
        ))

    for abbr in _LANE_ABBREVIATIONS:
        pat = re.compile(rf"\b{re.escape(abbr)}\b")
        for match in pat.finditer(scan_text):
            # Allow "CI" when preceded by "confidence interval" within the
            # prior ~40 chars (already spelled out).
            preface = scan_text[max(0, match.start() - 40):match.start()].lower()
            if abbr == "CI" and "confidence interval" in preface:
                continue
            if abbr == "df" and "degrees of freedom" in preface:
                continue
            if abbr == "MR" and "multiple regression" in preface:
                continue
            issues.append(Issue(
                criterion_id="mockup.lane.unexplained_abbreviation",
                severity="warning",
                message=(
                    f"Unexplained abbreviation '{abbr}' in faculty-facing "
                    "prose. Spell it out the first time, or keep it inside "
                    "a code block / verbatim MLO quote."
                ),
                location=_context_snippet(scan_text, match.start(), match.end()),
                fixable=False,
                fix_hint=f"Spell out '{abbr}' on first use.",
            ))

    bloom_verbs = ("Remember", "Understand", "Apply",
                   "Analyze", "Evaluate", "Create")
    for verb in bloom_verbs:
        pat = re.compile(rf"\b{verb}\b")
        for match in pat.finditer(scan_text):
            issues.append(Issue(
                criterion_id="mockup.lane.bloom_verb",
                severity="warning",
                message=(
                    f"Bloom verb '{verb}' in faculty-facing prose. Use the "
                    "verbatim MLO verb instead — faculty don't speak Bloom."
                ),
                location=_context_snippet(scan_text, match.start(), match.end()),
                fixable=False,
                fix_hint=(
                    "Quote the MLO verb verbatim, or drop the Bloom term."
                ),
            ))

    # LLM attribution badge must use ASU maroon (#8C1D40). The convention is
    # `<strong style="color:#8C1D40;">Example Generated by Claude ...`. A badge
    # that ships in default black (no maroon hex on the <strong>) reads as plain
    # body text and faculty can miss that the draft is AI-generated. Scan the
    # raw content (not scan_text) so <strong> attributes survive the strip.
    for match in _ATTRIBUTION_STRONG_RE.finditer(content):
        attrs = match.group(1) or ""
        if "color:#8c1d40" not in attrs.lower().replace(" ", ""):
            issues.append(Issue(
                criterion_id="mockup.lane.attribution_not_maroon",
                severity="warning",
                message=(
                    "LLM attribution badge ('Example Generated by Claude…') "
                    "is not ASU maroon. The convention is "
                    '<strong style="color:#8C1D40;"> — a black badge reads as '
                    "body text and faculty can miss the AI-generated flag."
                ),
                location=_context_snippet(content, match.start(), match.end()),
                fixable=False,
                fix_hint=(
                    'Add style="color:#8C1D40;" to the <strong> wrapping the '
                    "attribution badge."
                ),
            ))

    return issues


def check_body_no_title_repeat(html: str, title: str = "") -> list:
    """Reject body HTML whose first headings restate the Canvas object title.

    Canvas renders the page/assignment/discussion/quiz title via its own
    chrome. Bodies that lead with `<h1>` (any) or `<h2>` matching the title
    produce visible "Title / Title / heading" stacking for students.

    Rules:
      - Any `<h1>` in body → severity=error (blocks downstream gates that
        treat error as blocking).
      - `<h2>` whose normalized text equals the normalized title → warning.

    `title` is optional — when omitted, only the H1 rule fires.
    """
    issues = []
    if not html:
        return issues

    # Find all H1
    h1_re = re.compile(r"<h1\b[^>]*>(.*?)</h1>", flags=re.IGNORECASE | re.DOTALL)
    for match in h1_re.finditer(html):
        text = re.sub(r"<[^>]+>", "", match.group(1)).strip()
        issues.append(Issue(
            criterion_id="body.title.h1_present",
            severity="error",
            message=(
                "Body HTML contains an <h1>. Canvas renders the object "
                "title as the page H1 — duplicate H1 stacks visibly. Body "
                "content should start at H2 (top-level sections) or H3."
            ),
            location=_context_snippet(html, match.start(), match.end()),
            fixable=False,
            fix_hint=(
                "Remove the <h1>, or demote it to <h2>/<h3> as appropriate."
            ),
        ))

    if title:
        norm_title = re.sub(r"\s+", " ", title).strip().casefold()
        h2_re = re.compile(r"<h2\b[^>]*>(.*?)</h2>", flags=re.IGNORECASE | re.DOTALL)
        for match in h2_re.finditer(html):
            text = re.sub(r"<[^>]+>", "", match.group(1)).strip()
            norm_text = re.sub(r"\s+", " ", text).casefold()
            if norm_text == norm_title:
                issues.append(Issue(
                    criterion_id="body.title.h2_restates_title",
                    severity="warning",
                    message=(
                        f"<h2> restates the Canvas object title "
                        f"('{text}'). Students see the title in the page "
                        "chrome already — start with the first content "
                        "heading instead."
                    ),
                    location=_context_snippet(html, match.start(), match.end()),
                    fixable=False,
                    fix_hint=(
                        "Replace the restated title with the first content "
                        "heading, or drop the <h2> entirely."
                    ),
                ))

    return issues


def check_pre_overflow(html: str) -> list:
    """Warn on `<pre>` tags missing `white-space: pre-wrap`.

    Canvas page width is fixed; un-wrapped `<pre>` blocks overflow horizontally
    and break the page layout. The convention is:

        <pre style="white-space:pre-wrap;word-wrap:break-word;...">

    Severity=warning, non-blocking.
    """
    issues = []
    if not html:
        return issues

    pre_re = re.compile(r"<pre\b([^>]*)>", flags=re.IGNORECASE)
    for match in pre_re.finditer(html):
        attrs = match.group(1)
        style_match = re.search(r'style\s*=\s*"([^"]*)"', attrs, flags=re.IGNORECASE)
        style_match2 = re.search(r"style\s*=\s*'([^']*)'", attrs, flags=re.IGNORECASE)
        style = ""
        if style_match:
            style = style_match.group(1)
        elif style_match2:
            style = style_match2.group(1)
        # Look for white-space:pre-wrap (case-insensitive, whitespace-tolerant)
        if re.search(r"white-space\s*:\s*pre-wrap", style, flags=re.IGNORECASE):
            continue
        issues.append(Issue(
            criterion_id="body.pre.overflow",
            severity="warning",
            message=(
                "<pre> tag without 'white-space: pre-wrap' will overflow "
                "Canvas page width."
            ),
            location=_context_snippet(html, match.start(), match.end()),
            fixable=False,
            fix_hint=(
                "Add inline style: "
                'style="white-space:pre-wrap;word-wrap:break-word;"'
            ),
        ))

    return issues


# ── Staging meta completeness ─────────────────────────────────────────────

_STAGING_META_HEADING_RE = re.compile(
    r"<h[1-6][^>]*>(.*?)</h[1-6]>", re.DOTALL | re.IGNORECASE
)
_STAGING_META_TAG_RE = re.compile(r"<[^>]+>")
_STAGING_META_MODULE_RE = re.compile(r"^m(?:odule-?)?(\d+)", re.IGNORECASE)


def _has_heading_fallback(content: str) -> bool:
    if not content or not isinstance(content, str):
        return False
    m = _STAGING_META_HEADING_RE.search(content)
    if not m:
        return False
    text = _STAGING_META_TAG_RE.sub("", m.group(1)).strip()
    return bool(text)


def _has_module_filename_fallback(obj_id: str) -> bool:
    if not obj_id:
        return False
    return bool(_STAGING_META_MODULE_RE.match(obj_id))


def check_staging_meta_completeness(
    meta: dict | None,
    obj_id: str,
    object_type: str,
    content: str | None = None,
) -> list:
    """Warn when a staged artifact's .meta.json lacks render-relevant fields
    AND no auto-extraction fallback resolves them.

    The unified staging review uses meta.title + meta.module_number for TOC
    labels + module grouping. When either field is missing, the review surface
    attempts fallbacks:
        - title: first <h1>-<h6> text in HTML body (pages/assignments/
          discussions) or quiz JSON title (quizzes)
        - module_number: filename pattern `m{N}-…` / `module-{N}-…`

    This check warns only when neither the meta field nor the relevant
    fallback yields a value — i.e. the item *will* render degraded as
    "Unmapped Staged Content" or with the filename slug as title.

    Severity=warning, non-blocking.
    """
    issues = []
    meta = meta or {}

    has_title = bool(meta.get("title"))
    has_module = "module_number" in meta and meta.get("module_number") is not None

    # Title fallback: heading text for HTML bodies; JSON quiz title for quizzes
    title_fallback_ok = False
    if not has_title:
        if object_type in ("pages", "assignments", "discussions"):
            title_fallback_ok = _has_heading_fallback(content or "")
        elif object_type == "quizzes":
            try:
                qd = json.loads(content) if content else {}
                qtitle = (qd.get("quiz", {}).get("title") if isinstance(qd, dict) else None) \
                    or (qd.get("quiz_title") if isinstance(qd, dict) else None)
                title_fallback_ok = bool(qtitle)
            except (json.JSONDecodeError, TypeError, AttributeError):
                title_fallback_ok = False

    module_fallback_ok = has_module or _has_module_filename_fallback(obj_id)

    if not has_title and not title_fallback_ok:
        issues.append(Issue(
            criterion_id="staging.meta.missing_title",
            severity="warning",
            message=(
                "Staged item has no meta.title and no fallback resolved a "
                "title. Unified review will display the filename slug "
                f"('{obj_id}') instead of a readable title."
            ),
            location=f"{object_type}/{obj_id}.meta.json",
            fixable=False,
            fix_hint=(
                "Set meta.title in the .meta.json sidecar, or add a top-level "
                "<h1>-<h6> heading in the HTML body."
            ),
        ))

    if not module_fallback_ok:
        issues.append(Issue(
            criterion_id="staging.meta.missing_module",
            severity="warning",
            message=(
                "Staged item has no meta.module_number and filename "
                f"('{obj_id}') doesn't match the m{{N}}- pattern. Unified "
                "review will group this item as 'Unmapped Staged Content'."
            ),
            location=f"{object_type}/{obj_id}.meta.json",
            fixable=False,
            fix_hint=(
                "Set meta.module_number in the .meta.json sidecar, or rename "
                "the file to start with m{N}- (e.g. m2-homework.html)."
            ),
        ))

    return issues


# ═══════════════════════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════════════════════

def main():
    parser = argparse.ArgumentParser(description="Preflight quality checks for Canvas content")
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--check-page", action="store_true",
                       help="Check a single HTML page")
    group.add_argument("--check-module", action="store_true",
                       help="Check a module (requires --module and --config)")

    parser.add_argument("--html-file", help="Path to HTML file to check")
    parser.add_argument("--html-stdin", action="store_true", help="Read HTML from stdin")
    parser.add_argument("--module", type=int, help="Module number for --check-module")
    parser.add_argument("--config", help="Path to course-config.json")
    parser.add_argument("--slug", help="Page slug (for context)")
    parser.add_argument("--page-type", help="Page type override (overview, lesson, etc.)")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    parser.add_argument("--course-id", help="Canvas course ID (for ASUCB multi-course paths)")
    parser.add_argument(
        "--fix",
        action="store_true",
        help=(
            "Auto-fix deterministic issues in-place (with --check-page + --html-file). "
            "Applies fixers in order: dashes -> title-case -> table-bg. "
            "Writes fixed HTML back to --html-file (or stdout with --json). "
            "Reports per-fixer counts and any residual MANUAL issues."
        ),
    )

    args = parser.parse_args()

    if args.check_page:
        if args.html_stdin:
            html = sys.stdin.read()
        elif args.html_file:
            try:
                html = Path(args.html_file).read_text(encoding="utf-8")
            except FileNotFoundError:
                print(json.dumps({"ok": False, "error": f"File not found: {args.html_file}"}))
                sys.exit(1)
        else:
            parser.error("--check-page requires --html-file or --html-stdin")

        # Strip Canvas shell wrapper if present
        raw_start = '<!-- RAW_CONTENT_START -->'
        raw_end = '<!-- RAW_CONTENT_END -->'
        start_idx = html.find(raw_start)
        end_idx = html.find(raw_end)
        if start_idx != -1 and end_idx != -1:
            html = html[start_idx + len(raw_start):end_idx].strip()

        context = {}
        if args.slug:
            context["slug"] = args.slug
            context["page_type"] = args.page_type or _infer_page_type("", args.slug)
        if args.page_type:
            context["page_type"] = args.page_type

        # Load objectives from config if available
        if args.config:
            try:
                cfg = json.loads(Path(args.config).read_text(encoding="utf-8"))
                context["clos"] = [c.get("text", "") for c in cfg.get("clos", [])]
            except (json.JSONDecodeError, KeyError, OSError):
                pass

        if getattr(args, "fix", False):
            # ── --fix mode: apply deterministic fixers, write back, re-check ──
            try:
                import content_remediation as _cr
            except ImportError:
                print(
                    json.dumps({"ok": False, "error":
                        "content_remediation module not found; "
                        "ensure scripts/content_remediation.py is present."}),
                    file=sys.stderr,
                )
                sys.exit(1)

            html_original = html

            html, n_dashes = _cr.fix_dashes(html)
            html, n_case = _cr.fix_heading_title_case(html)
            html, n_tablebg = _cr.fix_table_row_background(html)
            _,   n_slop = _cr.fix_ai_slop_binary_contrast(html)

            fix_counts = {
                "fix_dashes": n_dashes,
                "fix_heading_title_case": n_case,
                "fix_table_row_background": n_tablebg,
                "fix_ai_slop_binary_contrast": n_slop,
            }
            total_fixed = n_dashes + n_case + n_tablebg

            # Write back to file (or stdout).
            if total_fixed > 0 and args.html_file:
                Path(args.html_file).write_text(html, encoding="utf-8")

            # Re-run check on fixed HTML to get residual issues.
            issues_after = check_page(html, context=context)
            summary_after = summarize_issues(issues_after)

            # Partition residual issues into fixable (should be 0) and manual.
            residual_fixable = [i for i in issues_after if i.fixable]
            residual_manual = [i for i in issues_after if not i.fixable]

            if args.json:
                print(json.dumps({
                    "ok": True,
                    "fix_counts": fix_counts,
                    "total_fixed": total_fixed,
                    "residual_fixable_count": len(residual_fixable),
                    "residual_manual_count": len(residual_manual),
                    **summary_after,
                }, indent=2))
            else:
                print(f"\n=== preflight --fix report ===")
                print(f"  fix_dashes:                {n_dashes} change(s)")
                print(f"  fix_heading_title_case:    {n_case} change(s)")
                print(f"  fix_table_row_background:  {n_tablebg} change(s)")
                print(f"  fix_ai_slop_binary_contrast: 0 (manual — see MANUAL issues)")
                print(f"  Total auto-fixed:          {total_fixed}")
                if args.html_file and total_fixed > 0:
                    print(f"  Written back to:           {args.html_file}")
                print(f"\n  Residual fixable issues:   {len(residual_fixable)}")
                print(f"  Residual MANUAL issues:    {len(residual_manual)}")
                if residual_fixable:
                    print("\n  WARNING: fixable issues remain after --fix run:")
                    for iss in residual_fixable:
                        print(f"    [{iss.criterion_id}] {iss.message}")
                if residual_manual:
                    print("\n  MANUAL issues (require human review):")
                    for iss in residual_manual:
                        print(f"    [{iss.severity.upper()}] [{iss.criterion_id}] {iss.message}")
                        if iss.fix_hint:
                            print(f"      Hint: {iss.fix_hint}")
                print("")
        else:
            issues = check_page(html, context=context)
            summary = summarize_issues(issues)

            if args.json:
                print(json.dumps({"ok": True, **summary}, indent=2))
            else:
                print(format_scorecard(args.slug or "Page", issues))

    elif args.check_module:
        if not args.module:
            parser.error("--check-module requires --module")
        if not args.config:
            parser.error("--check-module requires --config")

        try:
            cfg = json.loads(Path(args.config).read_text(encoding="utf-8"))
        except Exception as e:
            print(json.dumps({"ok": False, "error": f"Cannot load config: {e}"}))
            sys.exit(1)

        # Find the module in config
        mod_num = args.module
        mod_config = None
        for m in cfg.get("modules", []):
            if m.get("number") == mod_num:
                mod_config = m
                break

        if not mod_config:
            print(json.dumps({"ok": False, "error": f"Module {mod_num} not found in config"}))
            sys.exit(1)

        # Build context from config
        context = {
            "objectives": [o.get("text", "") for o in mod_config.get("objectives", [])],
            "clos": [c.get("text", "") for c in cfg.get("clos", [])],
            "module_number": mod_num,
        }

        # Build module_data from staged pages (ASUCB: courses/{courseId}/staging/pages/)
        plugin_root = Path(__file__).resolve().parents[1]
        course_id = args.course_id if hasattr(args, 'course_id') and args.course_id else None
        if course_id:
            staging_dir = plugin_root / "courses" / str(course_id) / "staging" / "pages"
        else:
            staging_dir = plugin_root / "staging"
        pages = []
        if staging_dir.exists():
            for f in staging_dir.glob(f"m{mod_num}-*.html"):
                if not f.name.startswith("_"):
                    raw_html = f.read_text(encoding="utf-8")
                    # Strip shell wrapper if present
                    raw_start = '<!-- RAW_CONTENT_START -->'
                    raw_end = '<!-- RAW_CONTENT_END -->'
                    si = raw_html.find(raw_start)
                    ei = raw_html.find(raw_end)
                    if si != -1 and ei != -1:
                        raw_html = raw_html[si + len(raw_start):ei].strip()
                    pages.append({
                        "title": f.stem,
                        "slug": f.stem,
                        "body": raw_html,
                    })

        module_data = {
            "name": mod_config.get("title", f"Module {mod_num}"),
            "number": mod_num,
            "pages": pages,
            "assignments": [],
            "quizzes": [],
            "discussions": [],
        }

        issues = check_module(module_data, context=context)
        summary = summarize_issues(issues)

        if args.json:
            print(json.dumps({"ok": True, **summary}, indent=2))
        else:
            mod_name = f"Module {mod_num}: {mod_config.get('title', '')}"
            print(format_scorecard(mod_name, issues))


if __name__ == "__main__":
    main()
