#!/usr/bin/env python3
"""Vision-assisted image alt-text auditor — manifest-for-agent (Option A).

For every <img> in Canvas pages, quizzes, assignments, and discussions:
  1. Extract structured ImageRecord from HTML.
  2. Run classify_heuristic() for obvious cases (equation, tiny/spacer, aria-hidden).
  3. Build alt-manifest.json for agent vision classification.
  4. Apply the deterministic verdict matrix once classifications arrive.
  5. Emit findings to backlog under a11y.wcag:1.1.1.

The script NEVER calls a vision API.  The calling agent reads the manifest,
views the downloaded images, and writes alt-classifications.json.

Usage:
    # Step 1 — build manifest + download images
    python audit_image_alt.py --course-id 255724 --build-manifest

    # Step 2 — agent fills alt-classifications.json, then apply verdicts
    python audit_image_alt.py --course-id 255724 --apply

    # Cache: courses/{id}/reports/vision/alt-cache.json (sha256 keyed)
"""

from __future__ import annotations

import argparse
import hashlib
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from typing import List, Optional
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))

import url_safety

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

try:
    import requests as _requests
    _REQUESTS_AVAILABLE = True
except ImportError:
    _REQUESTS_AVAILABLE = False

try:
    import canvas_api as _canvas_api
except Exception:
    _canvas_api = None  # type: ignore

try:
    import backlog_manager as _backlog_manager
except Exception:
    _backlog_manager = None  # type: ignore

# ── TypedDict-compatible ImageRecord ──────────────────────────────────────

class ImageRecord(dict):
    """Structured record for a single <img> extracted from HTML.

    Keys (all present after extract_images):
        image_id     stable hash of object_type+object_id+src
        object_type  "page" | "quiz" | "assignment" | "discussion"
        object_id    Canvas object ID (str)
        page_url     canonical URL of the containing object
        src          image src attribute (resolved)
        alt          str|None  — None = attribute absent, "" = empty string
        role         str  e.g. "presentation"
        aria_hidden  bool
        width        int|None
        height       int|None
        in_link      bool  — inside <a>
        in_figure    bool
        has_figcaption bool
        context_text str  nearby caption/paragraph (max 300 chars)
        is_equation  bool  (equation_images url OR alt starts LaTeX:)
        is_data_uri  bool
    """
    pass


# ── Filename-like alt regex (spec §5) ─────────────────────────────────────

_FILENAME_RE = re.compile(
    r"^\s*[\w .-]+\.(png|jpe?g|gif|svg|webp|bmp)\s*$",
    re.IGNORECASE,
)

_LATEX_PREFIX = re.compile(r"^\s*LaTeX:", re.IGNORECASE)


def _is_filename_like(alt: Optional[str]) -> bool:
    """Return True if alt looks like a raw filename."""
    if alt is None:
        return False
    return bool(_FILENAME_RE.match(alt))


# ── Classification constants ───────────────────────────────────────────────

RELEVANT = "relevant"
DECORATIVE = "decorative"

# ── Verdict codes ──────────────────────────────────────────────────────────

VERDICT_PASS = "pass"
VERDICT_ERROR = "error"
VERDICT_WARN = "warning"
VERDICT_INFO = "info"


class Verdict(dict):
    """Keys: code (pass/error/warning/info), severity, message, proposed_alt."""
    pass


# ── HTML parser for full ImageRecord extraction ────────────────────────────

class _ImgRecordParser(HTMLParser):
    """Stateful parser: tracks nesting (<a>, <figure>, <figcaption>) to populate
    in_link, in_figure, has_figcaption, and context_text for each <img>."""

    def __init__(self, course_id: str, object_type: str, object_id: str, page_url: str):
        super().__init__(convert_charrefs=True)
        self.course_id = course_id
        self.object_type = object_type
        self.object_id = str(object_id)
        self.page_url = page_url
        self.records: List[ImageRecord] = []

        # Nesting state
        self._link_depth = 0
        self._figure_depth = 0
        self._figcaption_depth = 0
        self._figcaption_texts: list = []
        self._para_depth = 0
        self._para_text = ""
        self._text_before = ""  # accumulate running text
        self._in_figcaption = False
        # Track img records created inside the current figure so we can
        # retroactively set has_figcaption when <figcaption> is encountered.
        self._figure_imgs: list = []  # records created at current figure depth

    # -- helpers --

    def _parse_dim(self, val: Optional[str]) -> Optional[int]:
        if val is None:
            return None
        try:
            return int(re.sub(r"[^\d]", "", val))
        except (ValueError, TypeError):
            return None

    def _build_image_id(self, src: str) -> str:
        raw = f"{self.object_type}:{self.object_id}:{src}"
        return hashlib.sha256(raw.encode()).hexdigest()[:16]

    # -- HTMLParser callbacks --

    def handle_data(self, data: str):
        self._text_before += data
        if self._in_figcaption:
            if self._figcaption_texts and isinstance(self._figcaption_texts[-1], list):
                self._figcaption_texts[-1].append(data)
        if self._para_depth > 0:
            self._para_text += data

    def handle_starttag(self, tag: str, attrs):
        d = dict(attrs)
        if tag == "a":
            self._link_depth += 1
        elif tag == "figure":
            self._figure_depth += 1
            self._figure_imgs = []
        elif tag == "figcaption":
            self._figcaption_depth += 1
            self._in_figcaption = True
            self._figcaption_texts.append([])
            # Any img already recorded inside this figure gets has_figcaption=True
            for r in self._figure_imgs:
                r["has_figcaption"] = True
        elif tag in ("p", "div", "span"):
            self._para_depth += 1
            self._para_text = ""
        elif tag == "img":
            src = d.get("src", "")
            alt_raw = d.get("alt")  # None = absent
            alt: Optional[str] = alt_raw  # may be "" (explicit empty)

            aria_hidden_val = d.get("aria-hidden", "").lower()
            aria_hidden = aria_hidden_val in ("true", "1")

            w = self._parse_dim(d.get("width") or d.get("style", ""))
            h = self._parse_dim(d.get("height"))
            # If width came from style parse failed, try inline style
            if w is None:
                style_w = re.search(r"width\s*:\s*(\d+)", d.get("style", ""))
                if style_w:
                    w = int(style_w.group(1))
            if h is None:
                style_h = re.search(r"height\s*:\s*(\d+)", d.get("style", ""))
                if style_h:
                    h = int(style_h.group(1))

            is_data_uri = src.startswith("data:")
            is_equation = (
                "/equation_images/" in src
                or (alt is not None and _LATEX_PREFIX.match(alt) is not None)
            )

            # Context text: last 150 chars of running text + figcaption text
            ctx_parts = []
            para = self._para_text.strip()
            if para:
                ctx_parts.append(para[-150:])
            # Most recent figcaption content
            for fc in reversed(self._figcaption_texts):
                text = "".join(fc).strip()
                if text:
                    ctx_parts.insert(0, f"[caption: {text}]")
                    break
            context_text = " ".join(ctx_parts)[:300]

            rec = ImageRecord(
                image_id=self._build_image_id(src),
                object_type=self.object_type,
                object_id=self.object_id,
                page_url=self.page_url,
                src=src,
                alt=alt,
                role=d.get("role", ""),
                aria_hidden=aria_hidden,
                width=w,
                height=h,
                in_link=self._link_depth > 0,
                in_figure=self._figure_depth > 0,
                has_figcaption=self._figure_depth > 0 and bool(self._figcaption_texts),
                context_text=context_text,
                is_equation=is_equation,
                is_data_uri=is_data_uri,
            )
            self.records.append(rec)
            if self._figure_depth > 0:
                self._figure_imgs.append(rec)

    def handle_endtag(self, tag: str):
        if tag == "a" and self._link_depth > 0:
            self._link_depth -= 1
        elif tag == "figure" and self._figure_depth > 0:
            self._figure_depth -= 1
        elif tag == "figcaption":
            self._figcaption_depth -= 1
            if self._figcaption_depth == 0:
                self._in_figcaption = False
        elif tag in ("p", "div", "span") and self._para_depth > 0:
            self._para_depth -= 1


# ── Public extraction API ──────────────────────────────────────────────────

def extract_images(
    course_id: str,
    object_type: str,
    object_id: str,
    html: str,
    *,
    page_url: str = "",
) -> List[ImageRecord]:
    """Parse HTML and return a list of ImageRecord dicts.

    Covers:
    - Pages, assignments, discussions: top-level body HTML.
    - Quizzes: pass question_text AND each answer's html/text as separate calls
      (caller responsibility — this function is per-HTML-fragment).

    alt=None means the attribute was absent.
    alt=""   means the attribute was present with empty value.
    """
    parser = _ImgRecordParser(
        course_id=str(course_id),
        object_type=object_type,
        object_id=str(object_id),
        page_url=page_url,
    )
    try:
        parser.feed(html or "")
    except Exception:
        pass
    return parser.records


# ── Heuristic classifier ───────────────────────────────────────────────────

def classify_heuristic(rec: ImageRecord) -> Optional[str]:
    """Return classification without vision for obvious cases, else None.

    Returns:
        "relevant"   — equation images
        "decorative" — tiny/spacer images, aria-hidden images
        None         — needs vision pass
    """
    # Equation images are always content-relevant
    if rec.get("is_equation"):
        return RELEVANT

    # aria-hidden="true" → explicitly hidden from AT → decorative intent
    if rec.get("aria_hidden"):
        return DECORATIVE

    # Tiny/spacer (either dimension <= 16px)
    w = rec.get("width")
    h = rec.get("height")
    if (w is not None and w <= 16) or (h is not None and h <= 16):
        return DECORATIVE

    # role="presentation" is a strong decorative signal (no vision needed)
    if rec.get("role", "").lower() in ("presentation", "none"):
        return DECORATIVE

    return None  # requires vision


# ── Verdict matrix ─────────────────────────────────────────────────────────

def verdict(rec: ImageRecord, classification: str) -> Verdict:
    """Pure function — no I/O.  Implements the spec verdict matrix exactly.

    Args:
        rec: ImageRecord (must have alt, role, aria_hidden, in_link, is_equation)
        classification: "relevant" | "decorative"

    Returns:
        Verdict dict with keys: code, severity, message, proposed_alt
    """
    alt = rec.get("alt")          # None = absent; "" = empty
    role = (rec.get("role") or "").lower()
    aria_hidden = rec.get("aria_hidden", False)

    is_marked_decorative = (
        alt == ""
        or role in ("presentation", "none")
        or aria_hidden
    )
    has_descriptive_alt = (
        alt is not None
        and alt.strip() != ""
        and not _is_filename_like(alt)
        and not _LATEX_PREFIX.match(alt)
    )
    is_latex_alt = alt is not None and _LATEX_PREFIX.match(alt) is not None
    is_filename = _is_filename_like(alt)

    if classification == RELEVANT:
        # Missing alt
        if alt is None:
            return Verdict(
                code=VERDICT_ERROR,
                severity="error",
                message="Content-relevant image has no alt attribute.",
                proposed_alt="[Describe the image content in 1-2 sentences.]",
            )
        # Filename-like alt
        if is_filename:
            return Verdict(
                code=VERDICT_ERROR,
                severity="error",
                message=f'Content-relevant image has a filename as alt text: "{alt}".',
                proposed_alt="[Replace filename with a descriptive alt text.]",
            )
        # Raw LaTeX: alt
        if is_latex_alt:
            return Verdict(
                code=VERDICT_WARN,
                severity="warning",
                message=f"Content-relevant image has raw LaTeX source as alt text.",
                proposed_alt="[Provide a human-readable mathematical description.]",
            )
        # Marked decorative but is relevant
        if is_marked_decorative:
            return Verdict(
                code=VERDICT_ERROR,
                severity="error",
                message="Content-relevant image is marked decorative (alt=\"\"/role/aria-hidden) — information hidden from AT.",
                proposed_alt="[Add descriptive alt text and remove decorative marking.]",
            )
        # In-link with empty alt → link has no accessible name
        if rec.get("in_link") and alt == "":
            return Verdict(
                code=VERDICT_ERROR,
                severity="error",
                message="Linked image has empty alt text — link has no accessible name.",
                proposed_alt="[Describe the link destination.]",
            )
        # Descriptive alt — pass
        return Verdict(
            code=VERDICT_PASS,
            severity="pass",
            message="Alt text is present and descriptive.",
            proposed_alt=None,
        )

    # classification == DECORATIVE
    if classification == DECORATIVE:
        # Correct decorative markup: alt="" AND role="presentation" (or aria-hidden)
        if alt == "" and (role in ("presentation", "none") or aria_hidden):
            return Verdict(
                code=VERDICT_PASS,
                severity="pass",
                message="Decorative image correctly marked (alt=\"\" + role/aria-hidden).",
                proposed_alt=None,
            )
        # Descriptive alt on a decorative image → screen-reader noise
        if has_descriptive_alt:
            return Verdict(
                code=VERDICT_INFO,
                severity="info",
                message="Decorative image has descriptive alt text (screen-reader noise); recommend marking decorative.",
                proposed_alt=None,
            )
        # Missing or absent alt, not properly marked
        return Verdict(
            code=VERDICT_WARN,
            severity="warning",
            message="Decorative image not properly marked; add alt=\"\" and role=\"presentation\".",
            proposed_alt=None,
        )

    # Unknown classification — surface as info
    return Verdict(
        code=VERDICT_INFO,
        severity="info",
        message=f"Unknown classification: {classification!r}.",
        proposed_alt=None,
    )


# ── Cache helpers ──────────────────────────────────────────────────────────

def _cache_path(course_id: str) -> Path:
    return PLUGIN_ROOT / "courses" / str(course_id) / "reports" / "vision" / "alt-cache.json"


def _load_cache(course_id: str) -> dict:
    p = _cache_path(course_id)
    if p.exists():
        try:
            return json.loads(p.read_text(encoding="utf-8"))
        except Exception:
            pass
    return {}


def _save_cache(course_id: str, cache: dict):
    p = _cache_path(course_id)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(json.dumps(cache, indent=2), encoding="utf-8")


def _sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


# ── Image downloader ───────────────────────────────────────────────────────

def _is_canvas_host(url: str, domain: str) -> bool:
    """Return True only for HTTPS URLs on the exact configured Canvas host."""
    parsed = urlparse(url)
    return (
        parsed.scheme.lower() == "https"
        and (parsed.hostname or "").lower() == str(domain or "").lower()
    )


def download_images(
    records: List[ImageRecord],
    dest: Path,
    *,
    canvas_domain: str = "",
    canvas_headers: Optional[dict] = None,
) -> dict:
    """Download images for manifest. Returns {image_id: local_path_or_None}.

    - data: URIs are not downloaded; their bytes are decoded inline.
    - Broken/unreachable → finding a11y.image_unreachable (warning), skip.
    - Canvas-hosted images use canvas_headers for auth.
    """
    dest = Path(dest)
    dest.mkdir(parents=True, exist_ok=True)

    result = {}

    for rec in records:
        image_id = rec["image_id"]
        src = rec["src"]

        if rec.get("is_data_uri"):
            # Decode data URI inline; write to file for manifest
            try:
                header, b64 = src.split(",", 1)
                import base64
                data = base64.b64decode(b64)
                local_path = dest / f"{image_id}.bin"
                local_path.write_bytes(data)
                result[image_id] = str(local_path)
            except Exception:
                result[image_id] = None
            continue

        if not url_safety.is_safe_fetch_url(src):
            _log.warning("Skipping unsafe image URL: %s", src)
            result[image_id] = None
            continue

        if not _REQUESTS_AVAILABLE:
            result[image_id] = None
            continue

        headers = {}
        if canvas_domain and _is_canvas_host(src, canvas_domain) and canvas_headers:
            headers = canvas_headers

        try:
            resp = _requests.get(src, headers=headers, timeout=15, stream=True)
            if resp.status_code != 200:
                _log.warning("Image unreachable (HTTP %s): %s", resp.status_code, src)
                result[image_id] = None
                continue
            # Determine extension
            ext = Path(urlparse(src).path).suffix or ".bin"
            ext = re.sub(r"[^\w.]", "", ext)[:10]
            local_path = dest / f"{image_id}{ext}"
            total = 0
            with local_path.open("wb") as f:
                for chunk in resp.iter_content(chunk_size=8192):
                    total += len(chunk)
                    if total > url_safety.MAX_FETCH_BYTES:
                        break
                    f.write(chunk)
            if total > url_safety.MAX_FETCH_BYTES:
                local_path.unlink(missing_ok=True)
                _log.warning("Image exceeded size cap, discarding: %s", src)
                result[image_id] = None
                continue
            result[image_id] = str(local_path)
        except Exception as exc:
            _log.warning("Failed to download %s: %s", src, exc)
            result[image_id] = None

    return result


# ── Manifest builder ───────────────────────────────────────────────────────

def build_manifest(course_id: str) -> Path:
    """Fetch all course content, extract images, apply heuristics, write manifest.

    Writes:
        courses/{id}/reports/vision/alt-manifest.json

    Returns:
        Path to manifest file.
    """
    if _canvas_api is None:
        raise RuntimeError("canvas_api not available — cannot fetch course content")

    config = _canvas_api.get_config(course_id=course_id)
    domain = config["domain"]
    headers = config["headers"]
    base = f"https://{domain}/api/v1/courses/{course_id}"

    def _get(path, params=None):
        """Paginated GET routed through canvas_api.paginated_get.

        Every Link: rel="next" URL is domain-validated before the Canvas Bearer
        token is attached — preventing token egress to an arbitrary host if a
        Canvas response supplies an off-domain next-link.

        Preserves the established "return [] on failure" contract so callers
        continue to skip failed resources gracefully rather than aborting the
        whole manifest build.
        """
        url = f"{base}/{path}" if not path.startswith("http") else path
        p = dict(params or {})
        p["per_page"] = 100
        try:
            return _canvas_api.paginated_get(
                url, headers, params=p, canvas_domain=domain
            )
        except (ValueError, _requests.RequestException):
            # ValueError = off-domain next-link; _requests.RequestException = HTTP failure.
            # Return [] to preserve the caller's skip-on-error semantics.
            return []

    all_records: List[ImageRecord] = []

    # Pages
    pages_meta = _get("pages")
    for pm in pages_meta:
        slug = pm.get("url", "")
        if not slug:
            continue
        r = _requests.get(f"{base}/pages/{slug}", headers=headers, timeout=30)
        if r.status_code != 200:
            continue
        page = r.json()
        html = page.get("body", "") or ""
        page_url = f"https://{domain}/courses/{course_id}/pages/{slug}"
        recs = extract_images(course_id, "page", slug, html, page_url=page_url)
        all_records.extend(recs)

    # Quizzes + questions
    quizzes = _get("quizzes")
    for q in quizzes:
        qid = q["id"]
        quiz_url = f"https://{domain}/courses/{course_id}/quizzes/{qid}"
        # Quiz description
        desc = q.get("description", "") or ""
        all_records.extend(
            extract_images(course_id, "quiz", str(qid), desc, page_url=quiz_url)
        )
        # Questions (question_text + answer html)
        questions = _get(f"quizzes/{qid}/questions")
        for qq in questions:
            qtext = qq.get("question_text", "") or ""
            all_records.extend(
                extract_images(course_id, "quiz", str(qid), qtext, page_url=quiz_url)
            )
            for ans in qq.get("answers", []):
                ans_html = ans.get("html", "") or ans.get("text", "") or ""
                if ans_html:
                    all_records.extend(
                        extract_images(course_id, "quiz", str(qid), ans_html, page_url=quiz_url)
                    )

    # Assignments
    assignments = _get("assignments")
    for a in assignments:
        aid = a["id"]
        desc = a.get("description", "") or ""
        aurl = f"https://{domain}/courses/{course_id}/assignments/{aid}"
        all_records.extend(
            extract_images(course_id, "assignment", str(aid), desc, page_url=aurl)
        )

    # Discussions
    discussions = _get("discussion_topics")
    for d in discussions:
        did = d["id"]
        msg = d.get("message", "") or ""
        durl = f"https://{domain}/courses/{course_id}/discussion_topics/{did}"
        all_records.extend(
            extract_images(course_id, "discussion", str(did), msg, page_url=durl)
        )

    # Apply heuristics and load cache
    cache = _load_cache(course_id)

    # Download images for manifest
    img_dir = PLUGIN_ROOT / "courses" / str(course_id) / "reports" / "vision" / "images"
    local_paths = download_images(
        all_records,
        img_dir,
        canvas_domain=domain,
        canvas_headers=headers,
    )

    # Compute sha256 for caching
    for rec in all_records:
        iid = rec["image_id"]
        lp = local_paths.get(iid)
        if lp and Path(lp).exists():
            try:
                rec["_sha256"] = _sha256_bytes(Path(lp).read_bytes())
            except Exception:
                rec["_sha256"] = None
        else:
            rec["_sha256"] = None

    # Pre-classify with heuristics; mark cache hits
    for rec in all_records:
        h_class = classify_heuristic(rec)
        if h_class:
            rec["heuristic_classification"] = h_class
        else:
            # Check cache by sha256
            sha = rec.get("_sha256")
            if sha and sha in cache:
                rec["cached_classification"] = cache[sha]
        rec["local_path"] = local_paths.get(rec["image_id"])

    manifest = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "course_id": str(course_id),
        "images": [dict(r) for r in all_records],
    }

    out_dir = PLUGIN_ROOT / "courses" / str(course_id) / "reports" / "vision"
    out_dir.mkdir(parents=True, exist_ok=True)
    manifest_path = out_dir / "alt-manifest.json"
    manifest_path.write_text(json.dumps(manifest, indent=2, default=str), encoding="utf-8")
    _log.info("Manifest written: %s (%d images)", manifest_path, len(all_records))
    return manifest_path


# ── Apply classifications ──────────────────────────────────────────────────

def apply_classifications(course_id: str) -> List[dict]:
    """Read manifest + classifications, apply verdict matrix, return findings.

    Reads:
        courses/{id}/reports/vision/alt-manifest.json
        courses/{id}/reports/vision/alt-classifications.json  (agent-written)

    Returns:
        List of finding dicts (criterion_id, severity, message, …)
    """
    out_dir = PLUGIN_ROOT / "courses" / str(course_id) / "reports" / "vision"
    manifest_path = out_dir / "alt-manifest.json"
    classifications_path = out_dir / "alt-classifications.json"

    if not manifest_path.exists():
        raise FileNotFoundError(f"Manifest not found: {manifest_path}")

    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    records = manifest.get("images", [])

    # Load agent classifications
    agent_classes: dict = {}
    if classifications_path.exists():
        raw = json.loads(classifications_path.read_text(encoding="utf-8"))
        for entry in raw:
            agent_classes[entry["image_id"]] = entry

    # Load cache
    cache = _load_cache(course_id)

    findings = []

    for rec in records:
        image_id = rec["image_id"]

        # Determine classification
        cls_entry = None
        if rec.get("heuristic_classification"):
            classification = rec["heuristic_classification"]
        elif rec.get("cached_classification"):
            classification = rec["cached_classification"]["classification"]
            cls_entry = rec["cached_classification"]
        elif image_id in agent_classes:
            cls_entry = agent_classes[image_id]
            classification = cls_entry["classification"]
        else:
            # No classification available — emit a manifest-pending finding
            findings.append({
                "criterion_id": "a11y.wcag:1.1.1",
                "severity": "info",
                "message": f"Image not yet classified (run vision pass): {rec.get('src', '')[:60]}",
                "object_type": rec.get("object_type"),
                "object_id": rec.get("object_id"),
                "page_url": rec.get("page_url"),
                "image_id": image_id,
                "proposed_alt": None,
            })
            continue

        # Update cache if we got a new agent classification
        if cls_entry and rec.get("_sha256"):
            sha = rec["_sha256"]
            if sha and sha not in cache:
                cache[sha] = {
                    "classification": classification,
                    "proposed_alt": cls_entry.get("proposed_alt"),
                    "rationale": cls_entry.get("rationale"),
                    "cached_at": datetime.now(timezone.utc).isoformat(),
                }

        # Apply verdict matrix
        v = verdict(ImageRecord(**{k: v for k, v in rec.items() if not k.startswith("_")}),
                    classification)

        if v["code"] == VERDICT_PASS:
            continue  # pass = no finding

        finding = {
            "criterion_id": "a11y.wcag:1.1.1",
            "severity": v["severity"],
            "message": v["message"],
            "object_type": rec.get("object_type"),
            "object_id": rec.get("object_id"),
            "page_url": rec.get("page_url"),
            "image_id": image_id,
            "src": rec.get("src", ""),
            "alt": rec.get("alt"),
            "classification": classification,
            "proposed_alt": v.get("proposed_alt"),
        }
        findings.append(finding)

    # Persist updated cache
    if cache:
        _save_cache(course_id, cache)

    # Emit to backlog
    _emit_to_backlog(course_id, findings)

    return findings


def _emit_to_backlog(course_id: str, findings: List[dict]):
    """Best-effort backlog emission for non-pass findings."""
    if _backlog_manager is None or not course_id:
        return
    for f in findings:
        if f.get("severity") not in ("error", "warning", "info"):
            continue
        fix_path = (
            f"Tell Claude: Address a11y.wcag:1.1.1 for image on "
            f"{f.get('object_type','?')}/{f.get('object_id','?')}. "
            + (f"Proposed alt: {f['proposed_alt']}" if f.get("proposed_alt") else "")
            + " Stage the smallest safe fix for preview before push."
        )
        item = {
            "finding_id": f"a11y.wcag:1.1.1:{f['image_id']}",
            "severity": f["severity"],
            "finding_text": f["message"],
            "fix": fix_path,
            "content_type": f.get("object_type", "page"),
            "object_id": str(f.get("object_id", "")),
            "object_ref": f.get("page_url", ""),
            "source": "audit_image_alt",
        }
        try:
            _backlog_manager.append_finding(str(course_id), item)
        except Exception as exc:
            _log.warning("Backlog emit failed for %s: %s", item["finding_id"], exc)


# ── criterion_evaluator integration ───────────────────────────────────────

def audit_image_alt_heuristic(course_id: str, pages: list) -> List[dict]:
    """Heuristic-only image alt audit (no network/vision) for criterion_evaluator.

    Uses pre-fetched page bodies in `pages` (list of dicts with 'body', 'url').
    Returns findings compatible with the Phase 2 pattern.
    """
    findings = []
    for page in pages:
        html = page.get("body", "") or ""
        page_url = page.get("html_url", "") or page.get("url", "")
        slug = page.get("url", "") or page.get("id", "")
        recs = extract_images(
            course_id, "page", str(slug), html, page_url=str(page_url)
        )
        for rec in recs:
            h_cls = classify_heuristic(rec)
            if h_cls is None:
                # Heuristic pass: check for obvious filename/missing alt issues
                alt = rec.get("alt")
                if alt is None:
                    findings.append({
                        "issue_type": "missing_alt",
                        "criterion_id": "a11y.wcag:1.1.1",
                        "severity": "warning",
                        "object_type": "page",
                        "object_id": str(slug),
                        "page_url": str(page_url),
                        "image_id": rec["image_id"],
                        "src": rec["src"],
                        "detail": f"Image missing alt text: {rec['src'][:60]}",
                    })
                elif _is_filename_like(alt):
                    findings.append({
                        "issue_type": "filename_alt",
                        "criterion_id": "a11y.wcag:1.1.1",
                        "severity": "warning",
                        "object_type": "page",
                        "object_id": str(slug),
                        "page_url": str(page_url),
                        "image_id": rec["image_id"],
                        "src": rec["src"],
                        "alt": alt,
                        "detail": f'Filename-like alt text: "{alt}"',
                    })
            # Equation with raw LaTeX alt → warning even in heuristic pass
            elif h_cls == RELEVANT:
                alt = rec.get("alt")
                if alt is not None and _LATEX_PREFIX.match(alt):
                    findings.append({
                        "issue_type": "latex_alt",
                        "criterion_id": "a11y.wcag:1.1.1",
                        "severity": "warning",
                        "object_type": "page",
                        "object_id": str(slug),
                        "page_url": str(page_url),
                        "image_id": rec["image_id"],
                        "src": rec["src"],
                        "alt": alt,
                        "detail": "Equation image has raw LaTeX source as alt text.",
                    })
    return findings


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

def main():
    parser = argparse.ArgumentParser(description="Image alt-text auditor (manifest-for-agent)")
    parser.add_argument("--course-id", required=True, help="Canvas course ID")
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--build-manifest", action="store_true",
                       help="Fetch course images, extract, download, write manifest")
    group.add_argument("--apply", action="store_true",
                       help="Apply classifications from alt-classifications.json")
    parser.add_argument("--json", action="store_true", help="Output findings as JSON")
    args = parser.parse_args()

    if args.build_manifest:
        path = build_manifest(args.course_id)
        print(f"Manifest: {path}")
    elif args.apply:
        findings = apply_classifications(args.course_id)
        if args.json:
            print(json.dumps(findings, indent=2, default=str))
        else:
            errors = [f for f in findings if f.get("severity") == "error"]
            warnings = [f for f in findings if f.get("severity") == "warning"]
            infos = [f for f in findings if f.get("severity") == "info"]
            print(f"Findings: {len(errors)} errors, {len(warnings)} warnings, {len(infos)} info")
            for f in findings:
                sev = f.get("severity", "?").upper()
                print(f"  [{sev}] {f.get('object_type')}/{f.get('object_id')}: {f.get('message')}")
                if f.get("proposed_alt"):
                    print(f"          proposed alt: {f['proposed_alt']}")


if __name__ == "__main__":
    main()
