#!/usr/bin/env python3
"""Static accessibility evidence collector for web codebases.

The scanner intentionally reports deterministic defects and review candidates.
It does not render pages, calculate final contrast, inspect the accessibility tree,
operate widgets, or establish WCAG conformance.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable, Sequence

MARKUP_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".cfm", ".cfml", ".php", ".phtml",
    ".jsp", ".jspx", ".aspx", ".ascx", ".cshtml", ".razor", ".vue",
    ".svelte", ".jsx", ".tsx",
}
STYLE_EXTENSIONS = {".css", ".scss", ".sass", ".less"}
SCRIPT_EXTENSIONS = {".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx", ".vue", ".svelte"}
ALL_EXTENSIONS = MARKUP_EXTENSIONS | STYLE_EXTENSIONS | SCRIPT_EXTENSIONS
EXCLUDED_DIRS = {
    ".git", ".svn", ".hg", "node_modules", "vendor", "dist", "build",
    "coverage", ".next", ".nuxt", ".cache", "tmp", "temp", "packages-lock",
}
MAX_FILE_SIZE = 2_000_000

SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
CONFIDENCE_ORDER = {"confirmed": 0, "high": 1, "medium": 2, "low": 3}

INTERACTIVE_TAGS = {
    "button", "summary", "select", "textarea", "details", "iframe",
}
INPUT_NON_LABEL_TYPES = {"hidden", "submit", "reset", "button", "image"}
FOCUSABLE_TAG_PATTERN = re.compile(
    r"<(?:a\b[^>]*\bhref\s*=|button\b|input\b(?![^>]*\btype\s*=\s*['\"]?hidden)|select\b|textarea\b|summary\b|"
    r"[^>]+\btabindex\s*=\s*['\"]?(?:0|[1-9]\d*))",
    re.IGNORECASE | re.DOTALL,
)


@dataclass(frozen=True)
class Finding:
    rule_id: str
    severity: str
    confidence: str
    wcag: str
    file: str
    line: int
    message: str
    evidence: str
    remediation: str


@dataclass
class ScanStats:
    scanned_files: int = 0
    skipped_large_files: int = 0
    skipped_minified_files: int = 0
    markup_files: int = 0
    style_files: int = 0
    script_files: int = 0


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Collect static accessibility findings from a web project."
    )
    parser.add_argument("path", type=Path, help="Project directory or file to inspect")
    parser.add_argument(
        "--format", choices=("markdown", "json"), default="markdown",
        help="Output format (default: markdown)",
    )
    parser.add_argument("--output", type=Path, help="Write output to this file")
    parser.add_argument(
        "--include-low-confidence", action="store_true",
        help="Include low-confidence heuristic findings",
    )
    parser.add_argument(
        "--fail-on", choices=("none", "critical", "high", "medium", "low"),
        default="none", help="Exit with status 2 when this severity or higher is found",
    )
    return parser.parse_args()


def iter_files(root: Path) -> Iterable[Path]:
    if root.is_file():
        if root.suffix.lower() in ALL_EXTENSIONS:
            yield root
        return

    for current, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS and not d.startswith(".")]
        for name in files:
            path = Path(current) / name
            if path.suffix.lower() in ALL_EXTENSIONS:
                yield path


def read_text(path: Path) -> str | None:
    try:
        if path.stat().st_size > MAX_FILE_SIZE:
            return None
        return path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return None


def is_probably_minified(path: Path, text: str) -> bool:
    if ".min." in path.name.lower():
        return True
    lines = text.splitlines()
    if not lines:
        return False
    longest = max(len(line) for line in lines)
    average = sum(len(line) for line in lines) / len(lines)
    return longest > 10_000 or (len(lines) < 8 and average > 1_500)


def relative_name(path: Path, root: Path) -> str:
    try:
        if root.is_file():
            return path.name
        return str(path.relative_to(root))
    except ValueError:
        return str(path)


def line_number(text: str, index: int) -> int:
    return text.count("\n", 0, max(0, index)) + 1


def compact(value: str, limit: int = 220) -> str:
    value = re.sub(r"\s+", " ", value).strip()
    return value if len(value) <= limit else value[: limit - 1] + "…"


def parse_attrs(tag: str) -> dict[str, str]:
    attrs: dict[str, str] = {}
    # Handles HTML, JSX, and common server-template attributes sufficiently for evidence collection.
    attr_re = re.compile(
        r"([:@\w-]+)\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|\{([^{}]*)\}|([^\s>]+))",
        re.DOTALL,
    )
    for match in attr_re.finditer(tag):
        name = match.group(1).lower()
        value = next((group for group in match.groups()[1:] if group is not None), "")
        attrs[name] = value.strip()
    # Boolean attributes.
    for name in ("disabled", "hidden", "autofocus", "required", "multiple", "controls"):
        if re.search(rf"(?:\s|<){name}(?:\s|/?>)", tag, re.IGNORECASE):
            attrs.setdefault(name, "")
    return attrs


def get_attr(attrs: dict[str, str], *names: str) -> str | None:
    for name in names:
        if name.lower() in attrs:
            return attrs[name.lower()]
    return None


def strip_tags(value: str) -> str:
    value = re.sub(r"<script\b.*?</script>", "", value, flags=re.IGNORECASE | re.DOTALL)
    value = re.sub(r"<style\b.*?</style>", "", value, flags=re.IGNORECASE | re.DOTALL)
    value = re.sub(r"<[^>]+>", " ", value)
    value = re.sub(r"&(?:nbsp|#160);", " ", value, flags=re.IGNORECASE)
    return re.sub(r"\s+", " ", value).strip()


def add(
    findings: list[Finding], *, rule_id: str, severity: str, confidence: str,
    wcag: str, file: str, line: int, message: str, evidence: str, remediation: str,
) -> None:
    findings.append(Finding(
        rule_id=rule_id,
        severity=severity,
        confidence=confidence,
        wcag=wcag,
        file=file,
        line=line,
        message=message,
        evidence=compact(evidence),
        remediation=remediation,
    ))


def scan_markup(text: str, file: str, findings: list[Finding]) -> dict[str, bool]:
    signals = {"animation": False, "reduced_motion": False}
    ids: dict[str, list[tuple[int, str]]] = {}
    labels_for: set[str] = set()

    for match in re.finditer(r"<([A-Za-z][\w:-]*)\b[^>]*>", text, re.DOTALL):
        tag_name = match.group(1).lower()
        tag = match.group(0)
        attrs = parse_attrs(tag)
        line = line_number(text, match.start())

        element_id = get_attr(attrs, "id")
        if element_id and not any(ch in element_id for ch in "{}<>$#"):
            ids.setdefault(element_id, []).append((line, tag))

        if tag_name == "label":
            target = get_attr(attrs, "for", "htmlfor")
            if target:
                labels_for.add(target)

        tabindex = get_attr(attrs, "tabindex")
        if tabindex and re.fullmatch(r"\+?[1-9]\d*", tabindex.strip()):
            add(findings, rule_id="A11Y-POSITIVE-TABINDEX", severity="medium", confidence="high",
                wcag="2.4.3", file=file, line=line,
                message="Positive tabindex creates a fragile, non-document focus order.", evidence=tag,
                remediation="Use source order and tabindex=\"0\" only where a custom focusable element is necessary.")

        if "autofocus" in attrs:
            add(findings, rule_id="A11Y-AUTOFOCUS-REVIEW", severity="low", confidence="medium",
                wcag="2.4.3, 3.2.1", file=file, line=line,
                message="Autofocus can cause unexpected focus movement and mobile keyboard activation.", evidence=tag,
                remediation="Remove autofocus unless the task and focus lifecycle have been tested with keyboard and assistive technology.")

        accesskey = get_attr(attrs, "accesskey")
        if accesskey is not None:
            add(findings, rule_id="A11Y-ACCESSKEY-REVIEW", severity="low", confidence="medium",
                wcag="2.1.4", file=file, line=line,
                message="Accesskey can conflict with browser and assistive-technology shortcuts.", evidence=tag,
                remediation="Remove it or provide a documented, configurable shortcut strategy that satisfies character-key shortcut requirements.")

        for ref_attr in ("aria-labelledby", "aria-describedby", "aria-controls", "aria-owns"):
            value = get_attr(attrs, ref_attr)
            if value:
                for ref in value.split():
                    if any(ch in ref for ch in "{}<>$#"):
                        continue
                    # Deferred resolution after all IDs are collected.
                    pass

        if tag_name == "img" and get_attr(attrs, "alt") is None:
            add(findings, rule_id="A11Y-IMG-ALT", severity="high", confidence="high",
                wcag="1.1.1", file=file, line=line,
                message="Image has no alt attribute, so its purpose cannot be determined consistently.", evidence=tag,
                remediation="Classify the image as informative, functional, complex, or decorative; then provide the appropriate alt value, including alt=\"\" for decorative images.")

        if tag_name == "input":
            input_type = (get_attr(attrs, "type") or "text").lower()
            if input_type == "image" and get_attr(attrs, "alt") is None:
                add(findings, rule_id="A11Y-INPUT-IMAGE-ALT", severity="high", confidence="high",
                    wcag="1.1.1, 4.1.2", file=file, line=line,
                    message="Image input has no alternative text or accessible name.", evidence=tag,
                    remediation="Provide an alt value that names the action, or replace the image input with a semantic button.")

        if tag_name == "iframe" and not (get_attr(attrs, "title", "aria-label", "aria-labelledby")):
            add(findings, rule_id="A11Y-IFRAME-TITLE", severity="medium", confidence="high",
                wcag="2.4.1, 4.1.2", file=file, line=line,
                message="Iframe has no programmatic title identifying its content or purpose.", evidence=tag,
                remediation="Add a concise, unique title describing the embedded content or function.")

        role = (get_attr(attrs, "role") or "").lower()
        if role == "button" and tag_name not in INTERACTIVE_TAGS:
            if tabindex is None:
                add(findings, rule_id="A11Y-ROLE-BUTTON-NO-TAB", severity="high", confidence="high",
                    wcag="2.1.1, 4.1.2", file=file, line=line,
                    message="Element with role=button is not in the sequential keyboard focus order.", evidence=tag,
                    remediation="Prefer a native button. Otherwise add complete keyboard activation and managed focus behavior.")

        aria_hidden = (get_attr(attrs, "aria-hidden") or "").lower()
        if aria_hidden == "true" and FOCUSABLE_TAG_PATTERN.search(tag):
            add(findings, rule_id="A11Y-ARIA-HIDDEN-FOCUSABLE", severity="high", confidence="high",
                wcag="4.1.2", file=file, line=line,
                message="A focusable element is marked aria-hidden=true.", evidence=tag,
                remediation="Remove it from focus as well as the accessibility tree, or remove aria-hidden so focused content remains exposed.")

        if tag_name in {"marquee", "blink"}:
            add(findings, rule_id="A11Y-OBSOLETE-MOVING-CONTENT", severity="high", confidence="high",
                wcag="2.2.2, 2.3.1", file=file, line=line,
                message=f"Obsolete <{tag_name}> content can move or blink without accessible controls.", evidence=tag,
                remediation="Replace it with static content or a controlled component that supports pause, stop, reduced motion, and flash limits.")

        if tag_name in {"div", "span", "li", "td"} and re.search(r"\bonclick\s*=", tag, re.IGNORECASE):
            if not role and tabindex is None:
                add(findings, rule_id="A11Y-CLICK-NONINTERACTIVE", severity="high", confidence="medium",
                    wcag="2.1.1, 4.1.2", file=file, line=line,
                    message="A non-interactive element has an inline click handler without keyboard semantics.", evidence=tag,
                    remediation="Replace it with a native button or link. If that is impossible, implement name, role, focus, Enter/Space activation, state, and disabled behavior.")

    for element_id, entries in ids.items():
        if len(entries) > 1:
            for line, tag in entries[1:]:
                add(findings, rule_id="A11Y-DUPLICATE-ID", severity="medium", confidence="high",
                    wcag="1.3.1, 4.1.2", file=file, line=line,
                    message=f"Duplicate id '{element_id}' can break labels, descriptions, and ARIA relationships.", evidence=tag,
                    remediation="Generate a unique, stable ID and update every for/ARIA reference to the intended element.")

    known_ids = set(ids)
    for match in re.finditer(r"<([A-Za-z][\w:-]*)\b[^>]*>", text, re.DOTALL):
        tag = match.group(0)
        attrs = parse_attrs(tag)
        line = line_number(text, match.start())
        for ref_attr in ("aria-labelledby", "aria-describedby", "aria-controls", "aria-owns"):
            value = get_attr(attrs, ref_attr)
            if not value:
                continue
            for ref in value.split():
                if any(ch in ref for ch in "{}<>$#"):
                    continue
                if ref not in known_ids:
                    severity = "high" if ref_attr == "aria-labelledby" else "medium"
                    add(findings, rule_id="A11Y-ARIA-REFERENCE-MISSING", severity=severity, confidence="high",
                        wcag="1.3.1, 4.1.2", file=file, line=line,
                        message=f"{ref_attr} references missing id '{ref}'.", evidence=tag,
                        remediation="Create the referenced element with a unique ID or remove/update the stale relationship.")

    # Document-level checks apply only when the file appears to contain a complete HTML document.
    has_html = re.search(r"<html\b", text, re.IGNORECASE) is not None
    if has_html:
        html_match = re.search(r"<html\b[^>]*>", text, re.IGNORECASE | re.DOTALL)
        if html_match:
            attrs = parse_attrs(html_match.group(0))
            if not get_attr(attrs, "lang"):
                add(findings, rule_id="A11Y-DOCUMENT-LANG", severity="medium", confidence="high",
                    wcag="3.1.1", file=file, line=line_number(text, html_match.start()),
                    message="HTML document has no default human-language declaration.", evidence=html_match.group(0),
                    remediation="Set a valid lang value on the html element and use lang on passages that change language.")

        title_match = re.search(r"<title\b[^>]*>(.*?)</title>", text, re.IGNORECASE | re.DOTALL)
        if not title_match or not strip_tags(title_match.group(1)):
            add(findings, rule_id="A11Y-DOCUMENT-TITLE", severity="medium", confidence="high",
                wcag="2.4.2", file=file, line=1,
                message="HTML document has no non-empty descriptive title.", evidence="<head>…</head>",
                remediation="Add a concise title identifying the page or current application view.")

        viewport_match = re.search(r"<meta\b[^>]*\bname\s*=\s*['\"]viewport['\"][^>]*>", text, re.IGNORECASE | re.DOTALL)
        if viewport_match:
            attrs = parse_attrs(viewport_match.group(0))
            content = (get_attr(attrs, "content") or "").lower().replace(" ", "")
            if "user-scalable=no" in content or re.search(r"maximum-scale=(?:0|1(?:\.0+)?)\b", content):
                add(findings, rule_id="A11Y-ZOOM-DISABLED", severity="high", confidence="high",
                    wcag="1.4.4", file=file, line=line_number(text, viewport_match.start()),
                    message="Viewport metadata restricts user zoom.", evidence=viewport_match.group(0),
                    remediation="Remove user-scalable=no and restrictive maximum-scale values; verify responsive reflow instead.")

    # Labels and controls.
    for match in re.finditer(r"<(input|select|textarea)\b[^>]*>", text, re.IGNORECASE | re.DOTALL):
        tag_name = match.group(1).lower()
        tag = match.group(0)
        attrs = parse_attrs(tag)
        input_type = (get_attr(attrs, "type") or "text").lower()
        if tag_name == "input" and input_type in INPUT_NON_LABEL_TYPES:
            continue
        if "hidden" in attrs or get_attr(attrs, "aria-hidden") == "true":
            continue
        element_id = get_attr(attrs, "id")
        named = bool(
            get_attr(attrs, "aria-label", "aria-labelledby")
            or (element_id and element_id in labels_for)
        )
        # Wrapping labels are accepted when the control appears inside a label block.
        preceding = text[max(0, match.start() - 600):match.start()]
        wrapping_label = preceding.lower().rfind("<label") > preceding.lower().rfind("</label")
        if not named and not wrapping_label:
            add(findings, rule_id="A11Y-FORM-LABEL", severity="high", confidence="high",
                wcag="1.3.1, 3.3.2, 4.1.2", file=file, line=line_number(text, match.start()),
                message=f"{tag_name} control has no detectable programmatic label.", evidence=tag,
                remediation="Add a persistent visible label associated by for/id, wrapping label, or another valid naming relationship.")

    # Empty buttons and links.
    for tag_name, rule_id, wcag in (
        ("button", "A11Y-EMPTY-BUTTON", "2.4.6, 4.1.2"),
        ("a", "A11Y-EMPTY-LINK", "2.4.4, 4.1.2"),
    ):
        pattern = re.compile(rf"<{tag_name}\b([^>]*)>(.*?)</{tag_name}>", re.IGNORECASE | re.DOTALL)
        for match in pattern.finditer(text):
            opening = f"<{tag_name}{match.group(1)}>"
            attrs = parse_attrs(opening)
            if tag_name == "a" and not get_attr(attrs, "href", "role", "tabindex"):
                continue
            visible_text = strip_tags(match.group(2))
            named = bool(visible_text or get_attr(attrs, "aria-label", "aria-labelledby", "title"))
            img_alt = re.search(r"<img\b[^>]*\balt\s*=\s*['\"]([^'\"]+)['\"]", match.group(2), re.IGNORECASE | re.DOTALL)
            if img_alt:
                named = True
            if not named:
                control_name = "Link" if tag_name == "a" else "Button"
                add(findings, rule_id=rule_id, severity="high", confidence="high",
                    wcag=wcag, file=file, line=line_number(text, match.start()),
                    message=f"{control_name} has no detectable accessible name.", evidence=compact(match.group(0)),
                    remediation=f"Provide a visible label or valid accessible name that describes the {('destination' if tag_name == 'a' else 'action')}.")

    # Heading sequence is a review signal, not an automatic conformance failure.
    levels: list[tuple[int, int, str]] = []
    for match in re.finditer(r"<h([1-6])\b[^>]*>(.*?)</h\1>", text, re.IGNORECASE | re.DOTALL):
        levels.append((int(match.group(1)), line_number(text, match.start()), compact(strip_tags(match.group(2)))))
    for previous, current in zip(levels, levels[1:]):
        if current[0] > previous[0] + 1:
            add(findings, rule_id="A11Y-HEADING-ORDER-REVIEW", severity="low", confidence="medium",
                wcag="1.3.1, 2.4.6", file=file, line=current[1],
                message=f"Heading level jumps from h{previous[0]} to h{current[0]}.", evidence=current[2] or f"h{current[0]}",
                remediation="Review whether the heading hierarchy represents the content structure; correct levels without choosing them for visual size.")

    # Media review.
    for match in re.finditer(r"<video\b[^>]*>(.*?)</video>", text, re.IGNORECASE | re.DOTALL):
        if not re.search(r"<track\b[^>]*\bkind\s*=\s*['\"]captions['\"]", match.group(1), re.IGNORECASE | re.DOTALL):
            add(findings, rule_id="A11Y-VIDEO-CAPTIONS-REVIEW", severity="medium", confidence="medium",
                wcag="1.2.2, 1.2.4", file=file, line=line_number(text, match.start()),
                message="Video element has no detectable captions track in this template.", evidence=compact(match.group(0)),
                remediation="Determine whether the media contains speech or meaningful audio and provide synchronized captions as required; account for runtime-injected tracks.")

    return signals


def scan_styles(text: str, file: str, findings: list[Finding]) -> dict[str, bool]:
    signals = {
        "animation": bool(re.search(r"@keyframes\b|\banimation(?:-name)?\s*:|\btransition\s*:", text, re.IGNORECASE)),
        "reduced_motion": "prefers-reduced-motion" in text.lower(),
    }

    for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", text, re.DOTALL):
        selector = compact(match.group(1), 160)
        body = match.group(2)
        line = line_number(text, match.start())
        if re.search(r":focus(?:-visible)?\b", selector, re.IGNORECASE):
            removes_outline = re.search(r"\boutline\s*:\s*(?:none|0(?:\s|;|$))", body, re.IGNORECASE)
            replacement = re.search(r"\b(?:box-shadow|border(?:-color|-width)?|outline-offset|text-decoration)\s*:", body, re.IGNORECASE)
            if removes_outline and not replacement:
                add(findings, rule_id="A11Y-FOCUS-OUTLINE-REMOVED", severity="high", confidence="medium",
                    wcag="2.4.7, 2.4.11", file=file, line=line,
                    message="Focus rule removes the outline without a detectable replacement indicator.", evidence=f"{selector} {{ {compact(body)} }}",
                    remediation="Keep the platform outline or add a tested focus indicator visible across themes and forced-colors mode.")

        if re.search(r"^(?:html|body|html\s*,\s*body)$", selector.strip(), re.IGNORECASE):
            if re.search(r"\boverflow(?:-x|-y)?\s*:\s*hidden\b", body, re.IGNORECASE):
                add(findings, rule_id="A11Y-ROOT-OVERFLOW-REVIEW", severity="medium", confidence="medium",
                    wcag="1.4.10, 2.1.2", file=file, line=line,
                    message="Root overflow is hidden and may clip zoomed content or create keyboard traps.", evidence=f"{selector} {{ {compact(body)} }}",
                    remediation="Verify all responsive, zoom, modal, and keyboard states; limit overflow locking to controlled transient states.")

        if re.search(r"\btext-size-adjust\s*:\s*none\b|-webkit-text-size-adjust\s*:\s*none\b", body, re.IGNORECASE):
            add(findings, rule_id="A11Y-TEXT-SIZE-ADJUST", severity="high", confidence="high",
                wcag="1.4.4", file=file, line=line,
                message="CSS disables automatic text-size adjustment.", evidence=f"{selector} {{ {compact(body)} }}",
                remediation="Remove text-size-adjust:none and make the layout resilient to enlarged text.")

        if re.search(r"\b(?:height|max-height)\s*:\s*\d+(?:px|vh)\b", body, re.IGNORECASE) and re.search(r"\b(?:font-size|line-height|overflow)\s*:", body, re.IGNORECASE):
            add(findings, rule_id="A11Y-FIXED-HEIGHT-TEXT-REVIEW", severity="low", confidence="low",
                wcag="1.4.4, 1.4.10, 1.4.12", file=file, line=line,
                message="Fixed height near text-related declarations may clip enlarged or re-spaced text.", evidence=f"{selector} {{ {compact(body)} }}",
                remediation="Prefer min-height and content-driven sizing; test 200% text size and WCAG text-spacing overrides.")

    if re.search(r"\bscroll-behavior\s*:\s*smooth\b", text, re.IGNORECASE) and not signals["reduced_motion"]:
        match = re.search(r"\bscroll-behavior\s*:\s*smooth\b", text, re.IGNORECASE)
        assert match is not None
        add(findings, rule_id="A11Y-SMOOTH-SCROLL-MOTION", severity="medium", confidence="medium",
            wcag="2.3.3", file=file, line=line_number(text, match.start()),
            message="Smooth scrolling is enabled without a detected reduced-motion override.", evidence=match.group(0),
            remediation="Disable smooth scrolling inside prefers-reduced-motion: reduce and verify programmatic focus remains visible.")

    return signals


def scan_scripts(text: str, file: str, findings: list[Finding]) -> dict[str, bool]:
    signals = {
        "animation": bool(re.search(r"requestAnimationFrame\s*\(|\.animate\s*\(|\bgsap\b|\bframer-motion\b", text, re.IGNORECASE)),
        "reduced_motion": bool(re.search(r"prefers-reduced-motion|matchMedia\s*\([^)]*reduced-motion", text, re.IGNORECASE)),
    }

    for match in re.finditer(r"\.setAttribute\s*\(\s*['\"]tabindex['\"]\s*,\s*['\"]?([1-9]\d*)", text, re.IGNORECASE):
        add(findings, rule_id="A11Y-SCRIPT-POSITIVE-TABINDEX", severity="medium", confidence="high",
            wcag="2.4.3", file=file, line=line_number(text, match.start()),
            message="Script assigns a positive tabindex and can create a non-document focus order.", evidence=match.group(0),
            remediation="Use DOM order, tabindex=0, or a documented composite-widget focus strategy.")

    for match in re.finditer(r"(?:document\.|\.)body\.style\.overflow\s*=\s*['\"]hidden['\"]", text, re.IGNORECASE):
        add(findings, rule_id="A11Y-SCRIPT-SCROLL-LOCK-REVIEW", severity="low", confidence="low",
            wcag="1.4.10, 2.1.2", file=file, line=line_number(text, match.start()),
            message="Script locks document scrolling; focus, zoom, and cleanup require manual review.", evidence=match.group(0),
            remediation="Limit scroll locking to active overlays, preserve scrollbar stability, and always restore state on every close and error path.")

    return signals


def deduplicate(findings: Sequence[Finding]) -> list[Finding]:
    seen: set[tuple[str, str, int, str]] = set()
    result: list[Finding] = []
    for finding in findings:
        key = (finding.rule_id, finding.file, finding.line, finding.evidence)
        if key not in seen:
            seen.add(key)
            result.append(finding)
    return result


def scan(root: Path, include_low_confidence: bool) -> tuple[list[Finding], ScanStats]:
    findings: list[Finding] = []
    stats = ScanStats()
    project_animation = False
    project_reduced_motion = False

    for path in iter_files(root):
        text = read_text(path)
        if text is None:
            try:
                if path.stat().st_size > MAX_FILE_SIZE:
                    stats.skipped_large_files += 1
            except OSError:
                pass
            continue
        if is_probably_minified(path, text):
            stats.skipped_minified_files += 1
            continue

        stats.scanned_files += 1
        file = relative_name(path, root)
        extension = path.suffix.lower()
        signals = {"animation": False, "reduced_motion": False}

        if extension in MARKUP_EXTENSIONS:
            stats.markup_files += 1
            current = scan_markup(text, file, findings)
            signals["animation"] |= current["animation"]
            signals["reduced_motion"] |= current["reduced_motion"]
        if extension in STYLE_EXTENSIONS:
            stats.style_files += 1
            current = scan_styles(text, file, findings)
            signals["animation"] |= current["animation"]
            signals["reduced_motion"] |= current["reduced_motion"]
        if extension in SCRIPT_EXTENSIONS:
            stats.script_files += 1
            current = scan_scripts(text, file, findings)
            signals["animation"] |= current["animation"]
            signals["reduced_motion"] |= current["reduced_motion"]

        project_animation |= signals["animation"]
        project_reduced_motion |= signals["reduced_motion"]

    if project_animation and not project_reduced_motion:
        add(findings, rule_id="A11Y-REDUCED-MOTION-REVIEW", severity="medium", confidence="medium",
            wcag="2.3.3", file="(project)", line=1,
            message="Animation or transition code was found, but no reduced-motion handling was detected in scanned files.",
            evidence="Project-level signal",
            remediation="Inventory motion, remove non-essential effects for prefers-reduced-motion: reduce, and manually verify essential feedback remains understandable.")

    findings = deduplicate(findings)
    if not include_low_confidence:
        findings = [finding for finding in findings if finding.confidence != "low"]
    findings.sort(key=lambda item: (
        SEVERITY_ORDER[item.severity], CONFIDENCE_ORDER[item.confidence],
        item.file.lower(), item.line, item.rule_id,
    ))
    return findings, stats


def summary(findings: Sequence[Finding]) -> dict[str, int]:
    result = {key: 0 for key in ("critical", "high", "medium", "low")}
    for finding in findings:
        result[finding.severity] += 1
    return result


def markdown_report(root: Path, findings: Sequence[Finding], stats: ScanStats) -> str:
    counts = summary(findings)
    lines = [
        "# Static accessibility inspection",
        "",
        f"- Target: `{root}`",
        f"- Files scanned: **{stats.scanned_files}**",
        f"- Markup files: **{stats.markup_files}**",
        f"- Style files: **{stats.style_files}**",
        f"- Script/component files: **{stats.script_files}**",
        f"- Skipped minified files: **{stats.skipped_minified_files}**",
        f"- Skipped oversized files: **{stats.skipped_large_files}**",
        "",
        "> This report contains static defects and review candidates. It does not render the product, test keyboard behavior, calculate final contrast, operate assistive technology, or establish WCAG conformance.",
        "",
        "## Summary",
        "",
        "| Severity | Count |",
        "| --- | ---: |",
        f"| Critical | {counts['critical']} |",
        f"| High | {counts['high']} |",
        f"| Medium | {counts['medium']} |",
        f"| Low | {counts['low']} |",
        "",
    ]

    if not findings:
        lines.extend([
            "No findings were emitted by the selected static rules.",
            "",
            "Manual and assistive-technology testing is still required.",
        ])
        return "\n".join(lines) + "\n"

    lines.extend(["## Findings", ""])
    for index, finding in enumerate(findings, start=1):
        lines.extend([
            f"### {index}. [{finding.severity.upper()}] {finding.message}",
            "",
            f"- Rule: `{finding.rule_id}`",
            f"- Confidence: **{finding.confidence}**",
            f"- WCAG: **{finding.wcag}**",
            f"- Location: `{finding.file}:{finding.line}`",
            f"- Evidence: `{finding.evidence.replace('`', "'")}`",
            f"- Remediation: {finding.remediation}",
            "",
        ])
    return "\n".join(lines)


def json_report(root: Path, findings: Sequence[Finding], stats: ScanStats) -> str:
    payload = {
        "target": str(root),
        "disclaimer": "Static evidence only; not a WCAG conformance determination.",
        "stats": asdict(stats),
        "summary": summary(findings),
        "findings": [asdict(finding) for finding in findings],
    }
    return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"


def should_fail(findings: Sequence[Finding], threshold: str) -> bool:
    if threshold == "none":
        return False
    limit = SEVERITY_ORDER[threshold]
    return any(SEVERITY_ORDER[finding.severity] <= limit for finding in findings)


def main() -> int:
    args = parse_args()
    target = args.path.resolve()
    if not target.exists():
        print(f"Error: path does not exist: {target}", file=sys.stderr)
        return 1

    findings, stats = scan(target, args.include_low_confidence)
    output = (
        json_report(target, findings, stats)
        if args.format == "json"
        else markdown_report(target, findings, stats)
    )

    if args.output:
        args.output.parent.mkdir(parents=True, exist_ok=True)
        args.output.write_text(output, encoding="utf-8")
    else:
        sys.stdout.write(output)

    return 2 if should_fail(findings, args.fail_on) else 0


if __name__ == "__main__":
    raise SystemExit(main())
