#!/usr/bin/env python3
"""Audit Canvas course conversion-readiness for in-person→online migrations.

Runs 7 deterministic detectors against already-fetched Canvas data and
produces a readiness score, per-finding backlog entries, and an HTML report.

CLI:
    python scripts/audit_conversion_readiness.py --course-id N
    python scripts/audit_conversion_readiness.py --course-id N --json
    python scripts/audit_conversion_readiness.py --course-id N --report
    python scripts/audit_conversion_readiness.py --course-id N --emit-backlog

Readiness signals:
    "Build, not port"               — any HIGH severity finding present
    "Conversion-ready with cleanup" — only MEDIUM / LOW findings, no HIGH
    "Online-ready"                  — zero findings across all detectors

Scoring is deterministic and documented here:
    score = 100 - (HIGH_count * 20 + MEDIUM_count * 10 + LOW_count * 3)
    Clamped to [0, 100]. Signal derived from severity counts, not raw score,
    so the label is stable regardless of exact numeric thresholds.
"""

from __future__ import annotations

import argparse
import html
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = Path(__file__).resolve().parent
if str(SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPTS_DIR))

from config_loader import load_course_config as _load_course_config_shared  # noqa: E402
from html_utils import strip_html as _strip_html_shared  # noqa: E402


# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------

Finding = dict[str, Any]  # {detector, severity, title, detail, evidence}

# Approved video hosts for detector 2.
# Host matching is EXACT — reuse the same pattern as canvas_api._is_canvas_response_url.
# Never substring-match. evil-drive.google.com.attacker.com must NOT match drive.google.com.
_APPROVED_HOSTS: frozenset[str] = frozenset({
    "mediaplus.asu.edu",
    "youtube.com",
    "www.youtube.com",
    "youtu.be",
})
_APPROVED_HOST_SUFFIXES: tuple[str, ...] = (
    ".instructuremedia.com",
    ".wistia.com",
    ".wistia.net",
    ".wi.st",
)
_HIGH_FLAGGED_HOSTS: frozenset[str] = frozenset({
    "drive.google.com",
    "www.drive.google.com",
    "dropbox.com",
    "www.dropbox.com",
})


# ---------------------------------------------------------------------------
# HTML helpers (match audit_rubrics.py)
# ---------------------------------------------------------------------------

def _strip_html(value: Any) -> str:
    """Strip HTML and decode entities.  Thin alias — delegates to :func:`html_utils.strip_html`."""
    return _strip_html_shared(value)


def _load_course_config(course_id: str | None = None, config_path: str | None = None) -> dict:
    """Load course config.  Thin alias — delegates to :func:`config_loader.load_course_config`."""
    return _load_course_config_shared(course_id=course_id, config_path=config_path)


# ---------------------------------------------------------------------------
# Host-guard (security) — EXACT-host matching only
# ---------------------------------------------------------------------------

def _exact_host(url: str) -> str:
    """Return the lowercased hostname from a URL, or empty string on failure."""
    try:
        parsed = urlparse(url)
        return (parsed.hostname or "").lower().strip()
    except Exception:
        return ""


def _is_approved_host(host: str, canvas_domain: str | None = None) -> bool:
    """Return True iff host is in the approved set — EXACT match, never substring.

    Security contract (mirrors canvas_api._is_canvas_response_url):
        "evil-drive.google.com.attacker.com" != "drive.google.com"  → False
        "youtube.com" == "youtube.com"                               → True
        "s3.wistia.com" endswith ".wistia.com"                      → True
        canvas_domain supplied and matches exactly                   → True
    """
    if not host:
        return False
    if host in _APPROVED_HOSTS:
        return True
    for suffix in _APPROVED_HOST_SUFFIXES:
        if host.endswith(suffix):
            return True
    if canvas_domain and host == canvas_domain.lower().strip():
        return True
    return False


def _host_severity(host: str) -> str:
    """Return HIGH for known risky hosts, MEDIUM for unknown off-host embeds."""
    if host in _HIGH_FLAGGED_HOSTS:
        return "HIGH"
    return "MEDIUM"


# ---------------------------------------------------------------------------
# Detector helpers
# ---------------------------------------------------------------------------

_IFRAME_RE = re.compile(r"<iframe\b[^>]*>", re.IGNORECASE)
_VIDEO_RE = re.compile(r"<video\b[^>]*>", re.IGNORECASE)
_SRC_RE = re.compile(r'\bsrc=["\']([^"\']+)["\']', re.IGNORECASE)
_HREF_RE = re.compile(r'\bhref=["\']([^"\']+)["\']', re.IGNORECASE)
_ANCHOR_RE = re.compile(r"<a\b[^>]*>.*?</a>", re.IGNORECASE | re.DOTALL)
_OBJECTIVES_RE = re.compile(r"objectives?", re.IGNORECASE)


def _extract_embed_urls(body_html: str) -> list[str]:
    """Return all src= URL values from <iframe> and <video> tags."""
    urls: list[str] = []
    for tag in _IFRAME_RE.findall(body_html) + _VIDEO_RE.findall(body_html):
        for src in _SRC_RE.findall(tag):
            urls.append(src)
    return urls


def _page_has_video_embed(body_html: str) -> bool:
    return bool(_IFRAME_RE.search(body_html) or _VIDEO_RE.search(body_html))


def _page_has_transcript_link(body_html: str) -> bool:
    """Check if page has a sibling link/file referencing transcript/caption."""
    text_lower = body_html.lower()
    keywords = ("transcript", "caption", ".vtt", ".srt")
    return any(k in text_lower for k in keywords)


def _page_text_length(body_html: str) -> int:
    """Count non-whitespace chars after stripping HTML."""
    stripped = _strip_html(body_html)
    return len(re.sub(r"\s", "", stripped))


# ---------------------------------------------------------------------------
# Detector 1: Bare media-embed pages
# ---------------------------------------------------------------------------

def detect_bare_media_embeds(pages: list[dict]) -> list[Finding]:
    """Detect pages that are little more than a raw video/iframe embed with
    almost no surrounding text (≤40 non-whitespace chars) — a lecture-capture
    file cabinet, not an async teaching artifact.

    Rule: body, after _strip_html, has ≤40 non-whitespace chars AND contains
    ≥1 <iframe or <video.

    Severity: HIGH
    """
    findings: list[Finding] = []
    for page in pages:
        body = page.get("body") or page.get("html") or ""
        if not body:
            continue
        has_embed = _page_has_video_embed(body)
        if not has_embed:
            continue
        char_count = _page_text_length(body)
        if char_count <= 40:
            findings.append({
                "detector": "bare_media_embed",
                "severity": "HIGH",
                "title": "Bare media-embed page",
                "detail": (
                    f"Page '{page.get('title', page.get('url', '?'))}' contains a video/iframe "
                    f"embed but only {char_count} non-whitespace text characters. "
                    "This is a lecture-capture page with no async teaching layer."
                ),
                "evidence": {
                    "page_id": page.get("id"),
                    "page_title": page.get("title") or page.get("url"),
                    "text_char_count": char_count,
                },
            })
    return findings


# ---------------------------------------------------------------------------
# Detector 2: Off-host / fragile video embeds
# ---------------------------------------------------------------------------

def detect_offhost_embeds(pages: list[dict], canvas_domain: str | None = None) -> list[Finding]:
    """Detect iframe/anchor URLs whose host is NOT in the approved set.

    Approved set: mediaplus.asu.edu, *.instructuremedia.com, youtube.com/youtu.be,
    *.wistia.*, the course's own CANVAS_DOMAIN (exact match).

    Flag drive.google.com, dropbox.com → HIGH.
    Other unknown off-host → MEDIUM.

    SECURITY: host matching is EXACT (reuses _exact_host / _is_approved_host).
    Substring tricks like "evil-drive.google.com.attacker.com" fail closed.
    The Canvas token is NEVER attached to off-domain URLs.
    """
    findings: list[Finding] = []
    seen: set[tuple[str, str]] = set()

    for page in pages:
        body = page.get("body") or page.get("html") or ""
        if not body:
            continue
        # Collect URLs from embed src and anchor href
        embed_urls = _extract_embed_urls(body)
        anchor_hrefs = _HREF_RE.findall(body)
        candidate_urls = embed_urls + anchor_hrefs

        for url in candidate_urls:
            host = _exact_host(url)
            if not host:
                continue
            if _is_approved_host(host, canvas_domain=canvas_domain):
                continue
            # Only flag http/https URLs (skip relative, mailto, etc.)
            parsed = urlparse(url)
            if parsed.scheme.lower() not in ("http", "https"):
                continue
            page_key = page.get("id") or page.get("url") or ""
            dedup_key = (str(page_key), host)
            if dedup_key in seen:
                continue
            seen.add(dedup_key)
            severity = _host_severity(host)
            findings.append({
                "detector": "offhost_embed",
                "severity": severity,
                "title": f"Off-host video embed ({host})",
                "detail": (
                    f"Page '{page.get('title', page.get('url', '?'))}' embeds content from "
                    f"'{host}' which is outside the approved hosting set. "
                    "Drive/Dropbox links are fragile and inaccessible to students without permissions."
                ),
                "evidence": {
                    "page_id": page.get("id"),
                    "page_title": page.get("title") or page.get("url"),
                    "host": host,
                    "url": url[:200],  # truncate for safety — never attach token
                },
            })
    return findings


# ---------------------------------------------------------------------------
# Detector 3: Caption/transcript gap
# ---------------------------------------------------------------------------

def detect_caption_gap(pages: list[dict]) -> list[Finding]:
    """Detect pages with a video embed but no adjacent transcript/caption link.

    Rule: page contains a video embed (iframe/video tag) AND the page body
    does not contain 'transcript', 'caption', '.vtt', or '.srt'.

    Severity: MEDIUM
    """
    findings: list[Finding] = []
    for page in pages:
        body = page.get("body") or page.get("html") or ""
        if not body:
            continue
        if not _page_has_video_embed(body):
            continue
        if _page_has_transcript_link(body):
            continue
        findings.append({
            "detector": "caption_gap",
            "severity": "MEDIUM",
            "title": "Video embed with no caption/transcript link",
            "detail": (
                f"Page '{page.get('title', page.get('url', '?'))}' has a video/iframe embed "
                "but no sibling link or file referencing a transcript, captions, .vtt, or .srt."
            ),
            "evidence": {
                "page_id": page.get("id"),
                "page_title": page.get("title") or page.get("url"),
            },
        })
    return findings


# ---------------------------------------------------------------------------
# Detector 4: Missing objectives
# ---------------------------------------------------------------------------

def detect_missing_objectives(pages: list[dict], modules: list[dict]) -> list[Finding]:
    """Detect courses that lack module overview pages with objectives headings.

    Two sub-checks:
      a) No Overview page exists at all for the course (no page whose title
         contains 'overview' case-insensitively).
      b) Overview pages that exist but lack a heading matching /objectives?/i.

    Severity: HIGH
    """
    findings: list[Finding] = []
    overview_pages = [
        p for p in pages
        if "overview" in (p.get("title") or p.get("url") or "").lower()
    ]

    if not overview_pages:
        findings.append({
            "detector": "missing_objectives",
            "severity": "HIGH",
            "title": "No module overview pages found",
            "detail": (
                "Course has no pages with 'Overview' in the title. "
                "An online course requires per-module overview pages with explicit learning objectives."
            ),
            "evidence": {
                "page_count": len(pages),
                "module_count": len(modules),
            },
        })
        return findings

    for page in overview_pages:
        body = page.get("body") or page.get("html") or ""
        # Check headings and plain text for objectives keyword
        if not _OBJECTIVES_RE.search(body):
            findings.append({
                "detector": "missing_objectives",
                "severity": "HIGH",
                "title": f"Overview page missing objectives heading",
                "detail": (
                    f"Overview page '{page.get('title', page.get('url', '?'))}' "
                    "does not contain a heading or text matching 'Objective(s)'. "
                    "Each module overview must state measurable learning objectives."
                ),
                "evidence": {
                    "page_id": page.get("id"),
                    "page_title": page.get("title") or page.get("url"),
                },
            })
    return findings


# ---------------------------------------------------------------------------
# Detector 5: Empty syllabus body
# ---------------------------------------------------------------------------

def detect_empty_syllabus(syllabus: dict) -> list[Finding]:
    """Detect courses with an empty or whitespace-only syllabus body.

    Rule: get_syllabus(...)['html'] is empty/whitespace after strip.

    Severity: MEDIUM
    """
    body = syllabus.get("html") or syllabus.get("syllabus_body") or ""
    if not _strip_html(body).strip():
        return [{
            "detector": "empty_syllabus",
            "severity": "MEDIUM",
            "title": "Empty syllabus body",
            "detail": (
                "The course syllabus body is empty. "
                "An online course requires a complete syllabus with CLOs, policies, and schedule."
            ),
            "evidence": {
                "syllabus_length": len(body),
            },
        }]
    return []


# ---------------------------------------------------------------------------
# Detector 6: No discussions
# ---------------------------------------------------------------------------

def detect_no_discussions(discussions: list[dict]) -> list[Finding]:
    """Detect courses with zero discussion topics.

    Rule: list_discussions count == 0.

    Severity: MEDIUM
    """
    if len(discussions) == 0:
        return [{
            "detector": "no_discussions",
            "severity": "MEDIUM",
            "title": "No discussion topics",
            "detail": (
                "The course has no discussion topics. "
                "Online courses require asynchronous discussion to replace in-person interaction."
            ),
            "evidence": {
                "discussion_count": 0,
            },
        }]
    return []


# ---------------------------------------------------------------------------
# Detector 7: Gradebook junk
# ---------------------------------------------------------------------------

def detect_gradebook_junk(assignments: list[dict], quizzes: list[dict]) -> list[Finding]:
    """Detect gradebook placeholder rows that inflate the gradebook without
    contributing to learning or grading.

    Rule:
      - Assignment with points_possible == 0 AND submission_types == ['none']
      - Quiz with question_count == 0

    Severity: LOW
    """
    findings: list[Finding] = []

    for asn in assignments:
        pts = asn.get("points_possible")
        sub_types = asn.get("submission_types") or []
        try:
            pts_float = float(pts) if pts is not None else None
        except (TypeError, ValueError):
            pts_float = None
        if pts_float == 0.0 and sub_types == ["none"]:
            findings.append({
                "detector": "gradebook_junk",
                "severity": "LOW",
                "title": f"Placeholder assignment: {asn.get('name', 'Untitled')}",
                "detail": (
                    f"Assignment '{asn.get('name', 'Untitled')}' has 0 points and "
                    "submission_type='none' — a gradebook placeholder row with no pedagogical value."
                ),
                "evidence": {
                    "assignment_id": asn.get("id"),
                    "assignment_name": asn.get("name"),
                    "points_possible": pts,
                    "submission_types": sub_types,
                },
            })

    for quiz in quizzes:
        q_count = quiz.get("question_count")
        try:
            q_count_int = int(q_count) if q_count is not None else None
        except (TypeError, ValueError):
            q_count_int = None
        if q_count_int == 0:
            findings.append({
                "detector": "gradebook_junk",
                "severity": "LOW",
                "title": f"Empty quiz: {quiz.get('title', 'Untitled')}",
                "detail": (
                    f"Quiz '{quiz.get('title', 'Untitled')}' has 0 questions — "
                    "an empty quiz that cannot assess student learning."
                ),
                "evidence": {
                    "quiz_id": quiz.get("id"),
                    "quiz_title": quiz.get("title"),
                    "question_count": q_count,
                },
            })

    return findings


# ---------------------------------------------------------------------------
# Scoring / signal
# ---------------------------------------------------------------------------

def compute_signal(findings: list[Finding]) -> tuple[int, str]:
    """Compute a numeric score and readiness signal from finding list.

    Returns (score: int, signal: str).

    Signal derivation (deterministic, severity-count based):
        Any HIGH finding          → "Build, not port"
        Only MEDIUM/LOW, no HIGH  → "Conversion-ready with cleanup"
        Zero findings             → "Online-ready"

    Score formula (clamped [0, 100]):
        score = 100 - (HIGH * 20 + MEDIUM * 10 + LOW * 3)
    """
    high = sum(1 for f in findings if f.get("severity") == "HIGH")
    medium = sum(1 for f in findings if f.get("severity") == "MEDIUM")
    low = sum(1 for f in findings if f.get("severity") == "LOW")

    score = max(0, min(100, 100 - (high * 20 + medium * 10 + low * 3)))

    if high > 0:
        signal = "Build, not port"
    elif medium > 0 or low > 0:
        signal = "Conversion-ready with cleanup"
    else:
        signal = "Online-ready"

    return score, signal


# ---------------------------------------------------------------------------
# Data fetch
# ---------------------------------------------------------------------------

def fetch_course_data(course_id: str) -> dict:
    """Fetch all Canvas data needed by the 7 detectors.

    Returns a dict with keys: pages, modules, discussions, assignments,
    quizzes, syllabus, canvas_domain.

    NOTE: Canvas token is NEVER attached to off-domain URLs.
    All API calls go through canvas_api which enforces exact-host validation.
    """
    import canvas_api  # noqa: PLC0415

    config = canvas_api.get_config(course_id=course_id)
    canvas_domain: str = config.get("domain") or "canvas.asu.edu"

    pages_summary = canvas_api.list_pages(config) or []
    pages_with_bodies: list[dict] = []
    for p in pages_summary:
        slug = p.get("url") or p.get("page_url")
        if slug:
            full = canvas_api.get_page(config, slug)
            if full:
                pages_with_bodies.append(full)
            else:
                pages_with_bodies.append(p)
        else:
            pages_with_bodies.append(p)

    modules = canvas_api.get_modules(config) or []
    discussions = canvas_api.list_discussions(config) or []
    assignments = canvas_api.list_assignments(config) or []
    quizzes = canvas_api.list_quizzes(config) or []
    syllabus = canvas_api.get_syllabus(config) or {"html": ""}

    return {
        "pages": pages_with_bodies,
        "modules": modules,
        "discussions": discussions,
        "assignments": assignments,
        "quizzes": quizzes,
        "syllabus": syllabus,
        "canvas_domain": canvas_domain,
    }


# ---------------------------------------------------------------------------
# Main audit function
# ---------------------------------------------------------------------------

def run_audit(data: dict) -> tuple[list[Finding], int, str]:
    """Run all 7 detectors against pre-fetched data. Returns (findings, score, signal).

    All detector functions are pure — they take data dicts, never make HTTP calls.
    """
    pages: list[dict] = data.get("pages") or []
    modules: list[dict] = data.get("modules") or []
    discussions: list[dict] = data.get("discussions") or []
    assignments: list[dict] = data.get("assignments") or []
    quizzes: list[dict] = data.get("quizzes") or []
    syllabus: dict = data.get("syllabus") or {}
    canvas_domain: str | None = data.get("canvas_domain")

    findings: list[Finding] = []
    findings.extend(detect_bare_media_embeds(pages))
    findings.extend(detect_offhost_embeds(pages, canvas_domain=canvas_domain))
    findings.extend(detect_caption_gap(pages))
    findings.extend(detect_missing_objectives(pages, modules))
    findings.extend(detect_empty_syllabus(syllabus))
    findings.extend(detect_no_discussions(discussions))
    findings.extend(detect_gradebook_junk(assignments, quizzes))

    score, signal = compute_signal(findings)
    return findings, score, signal


# ---------------------------------------------------------------------------
# JSON summary shape
# ---------------------------------------------------------------------------

def build_json_summary(course_id: str, findings: list[Finding], score: int, signal: str) -> dict:
    """Build the machine-readable summary for --json output / batch aggregation."""
    detectors: dict[str, dict] = {}
    for f in findings:
        det = f.get("detector", "unknown")
        if det not in detectors:
            detectors[det] = {"count": 0, "severity": f.get("severity", "INFO"), "items": []}
        detectors[det]["count"] += 1
        detectors[det]["items"].append({
            "title": f.get("title"),
            "detail": f.get("detail"),
            "evidence": f.get("evidence"),
        })
    return {
        "course_id": course_id,
        "score": score,
        "signal": signal,
        "detectors": detectors,
    }


# ---------------------------------------------------------------------------
# Backlog emission
# ---------------------------------------------------------------------------

_SEVERITY_PRIORITY_MAP = {"HIGH": "high", "MEDIUM": "medium", "LOW": "low"}


def emit_backlog(course_id: str, findings: list[Finding]) -> int:
    """Write findings to backlog via backlog_manager.add. Returns count written."""
    import backlog_manager  # noqa: PLC0415

    count = 0
    for idx, f in enumerate(findings):
        finding_id = f"cr-{f.get('detector', 'det')}-{idx + 1:03d}"
        item = {
            "finding_id": finding_id,
            "finding_text": f.get("title") or f.get("detail") or "Conversion-readiness finding",
            "severity": (f.get("severity") or "INFO").upper(),
            "priority": _SEVERITY_PRIORITY_MAP.get(f.get("severity", "").upper(), "low"),
            "source": "audit_conversion_readiness.py",
            "detector": f.get("detector"),
            "detail": f.get("detail"),
            "evidence": json.dumps(f.get("evidence") or {}),
            "content_type": "course",
            "status": "can_do_now",
        }
        try:
            backlog_manager.add(course_id, item)
            count += 1
        except Exception as exc:  # noqa: BLE001
            print(f"  WARNING: could not emit backlog item {finding_id}: {exc}", file=sys.stderr)
    return count


# ---------------------------------------------------------------------------
# HTML report
# ---------------------------------------------------------------------------

_SEVERITY_ICON = {"HIGH": "&#10060;", "MEDIUM": "&#9888;&#65039;", "LOW": "&#8505;&#65039;"}
_SEVERITY_BADGE = {"HIGH": "badge-fail", "MEDIUM": "badge-warn", "LOW": "badge-warn"}
_SEVERITY_CSS = {"HIGH": "fail", "MEDIUM": "warn", "LOW": "warn"}


def _html_escape(value: Any) -> str:
    return html.escape(str(value or ""), quote=True)


def _build_report_body(findings: list[Finding], score: int, signal: str) -> str:
    """Build the {{REPORT_BODY}} HTML fragment for insertion into report-base.html."""
    # Signal card
    signal_color = {
        "Build, not port": "#dc2626",
        "Conversion-ready with cleanup": "#f59e0b",
        "Online-ready": "#16a34a",
    }.get(signal, "#71717a")

    header_section = (
        f'<div class="report-section" style="margin-bottom:16px;">'
        f'<div class="report-section-header">'
        f'<span class="report-section-title">Readiness Signal</span>'
        f'</div>'
        f'<div class="report-section-body">'
        f'<p style="font-size:20px;font-weight:700;color:{_html_escape(signal_color)};">'
        f'{_html_escape(signal)}</p>'
        f'<p style="font-size:13px;color:#555;margin-top:6px;">'
        f'Readiness score: <strong>{_html_escape(score)}/100</strong></p>'
        f'</div></div>\n'
    )

    if not findings:
        return (
            header_section
            + '<div class="report-section"><div class="report-section-header">'
            + '<span class="report-section-title">All checks passed</span>'
            + '<span class="report-section-badge badge-pass">PASS</span></div>'
            + '<div class="report-section-body">'
            + '<p style="font-size:13px;color:#16a34a;">No conversion-readiness issues found.</p>'
            + "</div></div>\n"
        )

    # Group findings by detector
    by_detector: dict[str, list[Finding]] = {}
    for f in findings:
        det = f.get("detector", "unknown")
        by_detector.setdefault(det, []).append(f)

    sections_html = header_section
    for det, det_findings in by_detector.items():
        count = len(det_findings)
        max_sev = "HIGH" if any(f.get("severity") == "HIGH" for f in det_findings) else (
            "MEDIUM" if any(f.get("severity") == "MEDIUM" for f in det_findings) else "LOW"
        )
        badge_cls = _SEVERITY_BADGE.get(max_sev, "badge-warn")
        issue_label = f"{count} ISSUE{'S' if count != 1 else ''}"

        items_html = ""
        for idx, f in enumerate(det_findings):
            sev = f.get("severity", "INFO")
            icon = _SEVERITY_ICON.get(sev, "&#8505;&#65039;")
            evidence_str = ""
            ev = f.get("evidence") or {}
            if ev:
                ev_parts = [f"{_html_escape(k)}: {_html_escape(v)}" for k, v in ev.items()]
                evidence_str = (
                    f'<span class="issue-location">'
                    + " | ".join(ev_parts)
                    + "</span>"
                )
            items_html += (
                f'<li class="issue-item" data-issue-id="cr-{_html_escape(det)}-{idx+1:03d}" '
                f'data-severity="{_html_escape(sev.lower())}" data-fix-type="manual">'
                f'<div class="issue-row">'
                f'<span class="issue-severity">{icon}</span>'
                f'<div class="issue-content">'
                f'<span class="issue-text">{_html_escape(f.get("title",""))}: '
                f'{_html_escape(f.get("detail",""))}</span>'
                f"{evidence_str}"
                f'<p class="manual-note">Requires manual review before conversion.</p>'
                f"</div></div></li>\n"
            )

        sections_html += (
            f'<div class="report-section">'
            f'<div class="report-section-header">'
            f'<span class="report-section-title">{_html_escape(det.replace("_", " ").title())}</span>'
            f'<span class="report-section-badge {badge_cls}">{issue_label}</span>'
            f"</div>"
            f'<div class="report-section-body">'
            f'<ul class="issue-list">{items_html}</ul>'
            f"</div></div>\n"
        )

    return sections_html


def write_html_report(
    course_id: str,
    findings: list[Finding],
    score: int,
    signal: str,
    course_name: str = "",
) -> Path:
    """Write an HTML report to courses/{course_id}/reports/ and return its path."""
    template_path = PLUGIN_ROOT / "templates" / "report-html" / "report-base.html"
    template = template_path.read_text(encoding="utf-8")

    timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    report_date = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")

    high_c = sum(1 for f in findings if f.get("severity") == "HIGH")
    medium_c = sum(1 for f in findings if f.get("severity") == "MEDIUM")
    low_c = sum(1 for f in findings if f.get("severity") == "LOW")
    fail_count = high_c
    warn_count = medium_c + low_c
    pass_count = 7 - len({f.get("detector") for f in findings})  # detectors that fired 0 findings

    body_html = _build_report_body(findings, score, signal)

    report_html = (
        template
        .replace("{{REPORT_TITLE}}", f"Conversion-Readiness Audit")
        .replace("{{REPORT_TYPE}}", "Conversion-Readiness Audit")
        .replace("{{COURSE_NAME}}", _html_escape(course_name or f"Course {course_id}"))
        .replace("{{REPORT_DATE}}", report_date)
        .replace("{{PAGES_SCANNED}}", str(len(findings)))
        .replace("{{PASS_COUNT}}", str(pass_count))
        .replace("{{WARN_COUNT}}", str(warn_count))
        .replace("{{FAIL_COUNT}}", str(fail_count))
        .replace("{{REPORT_BODY}}", body_html)
    )

    report_dir = PLUGIN_ROOT / "courses" / str(course_id) / "reports"
    report_dir.mkdir(parents=True, exist_ok=True)
    report_path = report_dir / f"{timestamp}-conversion-readiness.html"
    report_path.write_text(report_html, encoding="utf-8")
    return report_path


# ---------------------------------------------------------------------------
# Human-readable summary
# ---------------------------------------------------------------------------

def print_summary(course_id: str, findings: list[Finding], score: int, signal: str) -> None:
    print(f"\n=== Conversion-Readiness Audit — Course {course_id} ===")
    print(f"Score: {score}/100  |  Signal: {signal}\n")

    if not findings:
        print("No issues found. Course appears online-ready.")
        return

    by_severity: dict[str, list[Finding]] = {}
    for f in findings:
        sev = f.get("severity", "INFO")
        by_severity.setdefault(sev, []).append(f)

    for sev in ("HIGH", "MEDIUM", "LOW"):
        sev_findings = by_severity.get(sev, [])
        if not sev_findings:
            continue
        print(f"[{sev}] ({len(sev_findings)} finding{'s' if len(sev_findings) != 1 else ''})")
        for f in sev_findings:
            print(f"  • {f.get('title')}")
            print(f"    {f.get('detail')}")
        print()


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(
        description="Audit Canvas course conversion-readiness for in-person→online migrations."
    )
    parser.add_argument("--course-id", required=True, help="Canvas course ID (numeric)")
    parser.add_argument(
        "--json",
        action="store_true",
        help="Machine-readable JSON summary to stdout",
    )
    parser.add_argument(
        "--report",
        action="store_true",
        help="Write HTML report to courses/{id}/reports/ and print its path",
    )
    parser.add_argument(
        "--emit-backlog",
        action="store_true",
        help="Write findings to backlog via backlog_manager.add",
    )
    parser.add_argument(
        "--pdf",
        action="store_true",
        help="Also export the HTML report to PDF (requires optional "
             "Playwright dependency). Implies --report.",
    )
    args = parser.parse_args()

    course_id = args.course_id.strip()
    if not re.match(r"^\d+$", course_id):
        parser.error(f"--course-id must be numeric, got: {course_id!r}")

    data = fetch_course_data(course_id)
    findings, score, signal = run_audit(data)

    if args.json:
        summary = build_json_summary(course_id, findings, score, signal)
        print(json.dumps(summary, indent=2, default=str))
        return

    print_summary(course_id, findings, score, signal)

    if args.report or args.pdf:
        path = write_html_report(course_id, findings, score, signal)
        print(f"Report saved to: {path}")

        if args.pdf:
            import report_pdf_exporter
            pdf_out = path.with_suffix(".pdf")
            try:
                pdf_path = report_pdf_exporter.export_pdf(
                    html_path=path, output_path=pdf_out,
                )
                print(f"PDF saved to: {pdf_path}")
            except report_pdf_exporter.PDFExportUnavailable as exc:
                if report_pdf_exporter.attempt_install():
                    try:
                        pdf_path = report_pdf_exporter.export_pdf(
                            html_path=path, output_path=pdf_out,
                        )
                        print(f"PDF saved to: {pdf_path}")
                    except report_pdf_exporter.PDFExportUnavailable as exc2:
                        print(f"PDF export skipped — {exc2}")
                else:
                    print(f"PDF export skipped — {exc}")

    if args.emit_backlog:
        count = emit_backlog(course_id, findings)
        print(f"Emitted {count} finding(s) to backlog.")


if __name__ == "__main__":
    main()
