#!/usr/bin/env python3
"""Reusable WCAG 2.1 A+AA static-HTML checks.

Extracted from ``check_interactive_a11y.py`` (PR1 of
docs/plan-wcag-2.1-aa-hardening.md) so the same WCAG validators can be
applied to ALL Canvas content surfaces (pages, quizzes, assignments,
discussions), not only interactive-activity HTML.

Stdlib only (``html.parser``). One-directional import: this module must
NEVER import ``check_interactive_a11y`` (that module imports this one).

Findings are plain dicts via ``issue()`` to preserve the interactive
deploy gate's byte-for-byte output. PR2 wraps these into preflight's
``Issue`` model at the call boundary; no parallel finding dataclass is
introduced here.

WCAG criteria checklist + compliance-matrix report format adapted from
the accessibility-test-scanner plugin in
jeremylongshore/claude-code-plugins-plus-skills (MIT, (c) 2025 Jeremy
Longshore). No upstream code copied verbatim; ASUCB implements its own
static-HTML checks.
"""

import re
from html.parser import HTMLParser

VOID_TAGS = {
    "area", "base", "br", "col", "embed", "hr", "img", "input",
    "link", "meta", "param", "source", "track", "wbr",
}


def issue(path, code, message, element=None):
    result = {"path": str(path), "code": code, "message": message}
    if element:
        result["element"] = element
    return result


class WCAGContentParser(HTMLParser):
    """Stateful WCAG 2.1 A+AA validator for a single HTML document.

    Tree-stateful checks (accessible-name + ARIA association via
    ``label[for]`` / control ``id``) require whole-tree context, so this
    is a parser class rather than per-tag pure functions. Feed the HTML,
    ``close()``, then ``finalize()``; read ``failures`` + ``warnings``.

    QB4 additions (wcag-unify):
    - Generic link text detection (WCAG 2.4.6 advisory / SC 2.4.4): fires on
      non-descriptive anchor text ("click here", "here", "link", "read more",
      "more").  Matches what audit_pages' fork checked.  Severity: warning.
    - Large-text contrast threshold (WCAG 1.4.3): headings (h1–h3) and
      elements with large font-size are checked at 3:1 instead of 4.5:1 when
      both foreground and background colors are declared inline.  The fork
      checked color-only against assumed white background; the shared engine
      requires an explicit background — that convergence is correct (color
      alone against white is speculative, not a WCAG finding).
    """

    # Non-descriptive anchor-text strings that are always generic link text.
    _GENERIC_LINK_TEXT = {"click here", "here", "link", "read more", "more"}

    def __init__(self, path):
        super().__init__(convert_charrefs=True)
        self.path = path
        self.failures = []
        self.warnings = []
        self.stack = []
        self.html_lang = False
        self.viewport = False
        self.canvas_wrapped = False
        self.headings = []
        self.label_for = set()
        self.controls = []
        # Link-text tracking (QB4)
        self._in_a = False
        self._a_text = ""
        self._a_href = ""

    def handle_comment(self, data):
        if "canvas-wrapped" in data:
            self.canvas_wrapped = True

    def handle_starttag(self, tag, attrs):
        self._start(tag, attrs, self_closing=False)

    def handle_startendtag(self, tag, attrs):
        self._start(tag, attrs, self_closing=True)

    def handle_data(self, data):
        for frame in self.stack:
            frame["text"] += data
        if self._in_a:
            self._a_text += data

    def handle_endtag(self, tag):
        tag = tag.lower()
        # Generic link text check (QB4: audit-only check preserved here)
        if tag == "a" and self._in_a:
            text = self._a_text.strip().lower()
            if text in self._GENERIC_LINK_TEXT:
                self.failures.append(issue(
                    self.path,
                    "WCAG 2.4.4",
                    f"Generic link text '{text}' is not descriptive",
                    f"a href={self._a_href[:60]!r}",
                ))
            self._in_a = False
            self._a_text = ""
            self._a_href = ""
        for idx in range(len(self.stack) - 1, -1, -1):
            if self.stack[idx]["tag"] == tag:
                del self.stack[idx:]
                break

    def _start(self, tag, attrs, self_closing=False):
        tag = tag.lower()
        attr = {name.lower(): (value if value is not None else "") for name, value in attrs}

        if tag == "html" and attr.get("lang", "").strip():
            self.html_lang = True

        if tag == "meta" and attr.get("name", "").lower() == "viewport" and attr.get("content", "").strip():
            self.viewport = True

        if tag in {"marquee", "blink"}:
            self.failures.append(issue(self.path, "WCAG 2.2.2", f"<{tag}> is not allowed", tag))

        if tag == "img":
            alt = attr.get("alt", "")
            decorative = attr.get("role", "").lower() == "presentation" or attr.get("aria-hidden", "").lower() == "true"
            if not alt.strip() and not decorative:
                self.failures.append(issue(self.path, "WCAG 1.1.1", "<img> needs non-empty alt or decorative semantics", "img"))

        if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
            self.headings.append((int(tag[1]), tag))

        # Track anchor text for generic-link-text check (QB4)
        if tag == "a":
            self._in_a = True
            self._a_text = ""
            self._a_href = attr.get("href", "")

        style = attr.get("style", "")
        if style:
            contrast_failure = contrast_issue(style, tag=tag)
            if contrast_failure:
                self.failures.append(issue(self.path, "WCAG 1.4.3", contrast_failure, tag))

        if "tabindex" in attr:
            tabindex = attr.get("tabindex", "").strip()
            if tabindex not in {"0", "-1"}:
                self.failures.append(issue(self.path, "WCAG 2.4.3", f"tabindex must be 0 or -1, found {tabindex!r}", tag))

        if attr.get("role", "").lower() == "radio" and "aria-checked" not in attr:
            self.failures.append(issue(self.path, "WCAG 4.1.2", "role='radio' requires aria-checked", tag))

        if tag == "button" and "btn-disabled" in attr.get("class", "").split():
            self.warnings.append(issue(self.path, "WARNING", "button.btn-disabled should be reviewed as disabled UI", "button"))

        if tag == "label" and attr.get("for", "").strip():
            self.label_for.add(attr["for"].strip())

        if tag in {"input", "select", "textarea", "button"}:
            self.controls.append({
                "tag": tag,
                "attrs": attr,
                "text": "",
                "in_label": any(frame["tag"] == "label" for frame in self.stack),
            })

        if tag not in VOID_TAGS and not self_closing:
            frame = {"tag": tag, "text": ""}
            self.stack.append(frame)
            if tag in {"input", "select", "textarea", "button"}:
                self.controls[-1]["frame"] = frame

    def finalize(self):
        if not self.html_lang:
            self.failures.append(issue(self.path, "WCAG 3.1.1", "<html lang='...'> is required", "html"))
        if not self.viewport:
            self.failures.append(issue(self.path, "WCAG 1.4.10", "<meta name='viewport' content='...'> is required", "meta"))

        self._check_headings()
        self._check_controls()

    def _check_headings(self):
        if not self.headings:
            return
        first_level = self.headings[0][0]
        if first_level != 1 and not (first_level == 2 and self.canvas_wrapped):
            self.failures.append(issue(
                self.path,
                "WCAG 1.3.1",
                "Heading hierarchy must start at <h1>, or <h2> with <!-- canvas-wrapped -->",
                self.headings[0][1],
            ))
        previous = first_level
        for level, tag in self.headings[1:]:
            if level > previous + 1:
                self.failures.append(issue(self.path, "WCAG 1.3.1", f"Heading level jumps from h{previous} to h{level}", tag))
            previous = level

    def _check_controls(self):
        for control in self.controls:
            tag = control["tag"]
            attr = control["attrs"]
            text = ""
            frame = control.get("frame")
            if frame:
                text = frame.get("text", "")
            has_name = bool(attr.get("aria-label", "").strip() or attr.get("aria-labelledby", "").strip())
            has_label = control["in_label"] or (attr.get("id", "").strip() in self.label_for)
            if tag == "button" and text.strip():
                continue
            if not has_name and not has_label:
                self.failures.append(issue(self.path, "WCAG 4.1.2", f"<{tag}> needs aria-label, aria-labelledby, or associated <label>", tag))


# Backward-compat alias: the interactive deploy gate historically used this
# class name. Keep it pointing at the shared parser so external importers and
# the interactive gate's byte-for-byte output are unchanged.
InteractiveA11yParser = WCAGContentParser


def parse_style(style):
    declarations = {}
    for part in style.split(";"):
        if ":" not in part:
            continue
        prop, value = part.split(":", 1)
        declarations[prop.strip().lower()] = value.strip().lower()
    return declarations


def parse_color(value):
    hex_match = re.search(r"#([0-9a-f]{3}|[0-9a-f]{6})\b", value, re.I)
    if hex_match:
        raw = hex_match.group(1)
        if len(raw) == 3:
            raw = "".join(ch * 2 for ch in raw)
        return tuple(int(raw[i:i + 2], 16) for i in (0, 2, 4))

    rgb_match = re.search(r"rgba?\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})", value, re.I)
    if rgb_match:
        values = tuple(int(rgb_match.group(i)) for i in range(1, 4))
        if all(0 <= value <= 255 for value in values):
            return values
    return None


def relative_luminance(rgb):
    parts = []
    for channel in rgb:
        c = channel / 255
        parts.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
    return 0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2]


def contrast_ratio(foreground, background):
    l1 = relative_luminance(foreground)
    l2 = relative_luminance(background)
    lighter = max(l1, l2)
    darker = min(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)


def contrast_issue(style, tag=None):
    """Check inline style for WCAG 1.4.3 contrast failure.

    Requires BOTH foreground (color:) and background (background-color: /
    background:) to be declared inline — a color alone against an assumed
    white background is speculative and not flagged.

    QB4 addition: ``tag`` (optional, lowercase) enables the WCAG 1.4.3
    large-text threshold.  Elements h1, h2, and h3 are considered large text
    (≥ 18pt or ≥ 14pt bold) and require only 3:1 instead of 4.5:1.
    """
    declarations = parse_style(style)
    foreground = parse_color(declarations.get("color", ""))
    background = parse_color(declarations.get("background-color", "") or declarations.get("background", ""))
    if not foreground or not background:
        return None
    ratio = contrast_ratio(foreground, background)
    # Large-text elements (WCAG 1.4.3): h1/h2/h3 treated as ≥18pt/bold.
    large_text_tags = {"h1", "h2", "h3"}
    threshold = 3.0 if (tag and tag.lower() in large_text_tags) else 4.5
    if ratio < threshold:
        return (
            f"Inline color/background contrast is {ratio:.2f}:1; "
            f"minimum is {threshold:.1f}:1"
        )
    return None
