#!/usr/bin/env python3
"""Collect static evidence about web interaction and motion architecture.

The scanner reports source-level risks and review prompts. It does not prove
WCAG conformance, browser support, runtime performance, or vestibular safety.
"""

from __future__ import annotations

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

SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
TEXT_EXTENSIONS = {
    ".css", ".scss", ".sass", ".less", ".html", ".htm", ".xhtml", ".js", ".mjs", ".cjs",
    ".ts", ".tsx", ".jsx", ".vue", ".svelte", ".astro", ".php", ".cfm", ".cfc", ".jsp",
    ".jspx", ".asp", ".aspx", ".cshtml", ".vbhtml", ".razor", ".json", ".yaml", ".yml",
    ".xml", ".svg", ".md", ".txt",
}
STYLE_EXTENSIONS = {".css", ".scss", ".sass", ".less", ".vue", ".svelte", ".astro"}
MARKUP_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".php", ".cfm", ".jsp", ".jspx", ".asp", ".aspx",
    ".cshtml", ".vbhtml", ".razor", ".vue", ".svelte", ".astro",
}
SCRIPT_EXTENSIONS = {".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".vue", ".svelte", ".astro"}
SKIP_DIRS = {
    ".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "coverage", ".next",
    ".nuxt", ".svelte-kit", ".angular", ".cache", ".parcel-cache", "target", "bin", "obj",
}
REPORT_BASENAMES = {
    "interaction-motion-report.md", "interaction-motion-report.json", "motion-report.md", "motion-report.json",
}

PACKAGE_SIGNALS = {
    "framer-motion": "Framer Motion",
    "motion": "Motion",
    "@motionone/dom": "Motion One",
    "gsap": "GSAP",
    "animejs": "Anime.js",
    "anime.js": "Anime.js",
    "lottie-web": "Lottie",
    "@lottiefiles/dotlottie-web": "dotLottie",
    "swiper": "Swiper",
    "embla-carousel": "Embla Carousel",
    "slick-carousel": "Slick Carousel",
    "react-slick": "React Slick",
    "sortablejs": "SortableJS",
    "react-beautiful-dnd": "react-beautiful-dnd",
    "@hello-pangea/dnd": "hello-pangea/dnd",
    "@dnd-kit/core": "dnd-kit",
    "interactjs": "interact.js",
    "hammerjs": "Hammer.js",
    "jquery-ui": "jQuery UI",
    "jquery": "jQuery",
    "bootstrap": "Bootstrap",
}
FRAMEWORK_SIGNALS = {
    "react": "React", "vue": "Vue", "@angular/core": "Angular", "svelte": "Svelte",
    "next": "Next.js", "nuxt": "Nuxt", "astro": "Astro",
}
LAYOUT_PROPERTIES = {
    "width", "height", "min-width", "max-width", "min-height", "max-height", "top", "right", "bottom", "left",
    "margin", "margin-top", "margin-right", "margin-bottom", "margin-left", "padding", "padding-top",
    "padding-right", "padding-bottom", "padding-left", "grid-template-columns", "grid-template-rows", "flex-basis",
    "font-size", "line-height", "border-width", "border-radius",
}


@dataclass(frozen=True)
class Finding:
    rule_id: str
    severity: str
    confidence: str
    area: str
    file: str
    line: int
    title: str
    evidence: str
    impact: str
    remediation: str
    manual_check: str = ""


@dataclass
class Inventory:
    root: str
    files_scanned: int = 0
    bytes_scanned: int = 0
    style_files: int = 0
    markup_files: int = 0
    script_files: int = 0
    keyframes: int = 0
    transitions: int = 0
    css_animations: int = 0
    waapi_calls: int = 0
    raf_calls: int = 0
    gesture_handlers: int = 0
    drag_signals: int = 0
    view_transition_signals: int = 0
    reduced_motion_signals: int = 0
    libraries: list[str] | None = None
    frameworks: list[str] | None = None
    package_versions: dict[str, str] | None = None

    def __post_init__(self) -> None:
        self.libraries = self.libraries or []
        self.frameworks = self.frameworks or []
        self.package_versions = self.package_versions or {}


class Scanner:
    def __init__(self, root: Path, output_path: Path | None = None, max_bytes: int = 2_000_000) -> None:
        self.root = root.resolve()
        self.output_path = output_path.resolve() if output_path else None
        self.max_bytes = max_bytes
        self.findings: list[Finding] = []
        self.inventory = Inventory(root=str(self.root))
        self.contents: dict[Path, str] = {}
        self.lines: dict[Path, list[str]] = {}
        self.package_versions: dict[str, str] = {}
        self.keyframes: dict[str, list[tuple[Path, int]]] = defaultdict(list)
        self.view_names: dict[str, list[tuple[Path, int]]] = defaultdict(list)
        self.motion_sources = 0
        self.reduced_motion_files: set[Path] = set()
        self.pause_control_signals = 0
        self.drag_alternative_signals = 0
        self.test_signals = 0

    def rel(self, path: Path) -> str:
        try:
            return str(path.resolve().relative_to(self.root)).replace(os.sep, "/")
        except ValueError:
            return str(path)

    def add(
        self,
        rule_id: str,
        severity: str,
        confidence: str,
        area: str,
        path: Path,
        line: int,
        title: str,
        evidence: str,
        impact: str,
        remediation: str,
        manual_check: str = "",
    ) -> None:
        self.findings.append(
            Finding(
                rule_id=rule_id,
                severity=severity,
                confidence=confidence,
                area=area,
                file=self.rel(path),
                line=max(1, line),
                title=title,
                evidence=evidence.strip()[:500],
                impact=impact,
                remediation=remediation,
                manual_check=manual_check,
            )
        )

    def iter_files(self) -> Iterable[Path]:
        if self.root.is_file():
            yield self.root
            return
        for path in self.root.rglob("*"):
            if not path.is_file():
                continue
            if any(part in SKIP_DIRS for part in path.parts):
                continue
            if path.name.lower() in REPORT_BASENAMES:
                continue
            if self.output_path and path.resolve() == self.output_path:
                continue
            if path.suffix.lower() not in TEXT_EXTENSIONS:
                continue
            try:
                if path.stat().st_size > self.max_bytes:
                    continue
            except OSError:
                continue
            yield path

    def load(self) -> None:
        for path in self.iter_files():
            try:
                raw = path.read_bytes()
                text = raw.decode("utf-8", errors="replace")
            except OSError:
                continue
            self.contents[path] = text
            self.lines[path] = text.splitlines()
            self.inventory.files_scanned += 1
            self.inventory.bytes_scanned += len(raw)
            suffix = path.suffix.lower()
            if suffix in STYLE_EXTENSIONS:
                self.inventory.style_files += 1
            if suffix in MARKUP_EXTENSIONS:
                self.inventory.markup_files += 1
            if suffix in SCRIPT_EXTENSIONS:
                self.inventory.script_files += 1

    def scan_packages(self) -> None:
        for path, text in self.contents.items():
            if path.name not in {"package.json", "bower.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"}:
                continue
            if path.name in {"package.json", "bower.json"}:
                try:
                    data = json.loads(text)
                except json.JSONDecodeError:
                    continue
                deps: dict[str, object] = {}
                for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
                    value = data.get(key, {})
                    if isinstance(value, dict):
                        deps.update(value)
                for name, version in deps.items():
                    self.package_versions[name] = str(version)
            else:
                for name in list(PACKAGE_SIGNALS) + list(FRAMEWORK_SIGNALS):
                    if name in text and name not in self.package_versions:
                        self.package_versions[name] = "lockfile"

        libraries = {label for name, label in PACKAGE_SIGNALS.items() if name in self.package_versions}
        frameworks = {label for name, label in FRAMEWORK_SIGNALS.items() if name in self.package_versions}
        self.inventory.libraries = sorted(libraries)
        self.inventory.frameworks = sorted(frameworks)
        self.inventory.package_versions = dict(sorted(self.package_versions.items()))

        engines = libraries.intersection({
            "Framer Motion", "Motion", "Motion One", "GSAP", "Anime.js", "Lottie", "dotLottie",
            "Swiper", "Embla Carousel", "Slick Carousel", "React Slick", "SortableJS", "dnd-kit",
            "hello-pangea/dnd", "react-beautiful-dnd", "interact.js", "Hammer.js", "jQuery UI",
        })
        if len(engines) >= 4:
            anchor = self.find_package_anchor()
            self.add(
                "MOT001", "medium", "high", "architecture", anchor, 1,
                "Many interaction or animation libraries are installed",
                ", ".join(sorted(engines)),
                "Overlapping engines increase bundle cost, lifecycle complexity, and inconsistent reduced-motion behavior.",
                "Map each library to owned patterns, consolidate duplicate capabilities, and define an approved adapter layer.",
                "Confirm which packages are shipped to production rather than tooling-only dependencies.",
            )

        obsolete = {
            "react-beautiful-dnd": "The project is archived; plan a supported replacement or maintained fork.",
            "hammerjs": "Hammer.js is legacy-oriented; verify maintenance, browser needs, and pointer-event alternatives.",
        }
        for name, message in obsolete.items():
            if name in self.package_versions:
                anchor = self.find_package_anchor(name)
                self.add(
                    "MOT002", "medium", "high", "dependencies", anchor, 1,
                    f"Legacy interaction dependency detected: {name}",
                    f"{name}: {self.package_versions[name]}",
                    "Unsupported or stagnant interaction dependencies can block accessibility and browser fixes.",
                    message,
                    "Verify current upstream status and actual production usage before replacement.",
                )

    def find_package_anchor(self, needle: str | None = None) -> Path:
        for path, text in self.contents.items():
            if path.name in {"package.json", "bower.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"}:
                if needle is None or needle in text:
                    return path
        return self.root if self.root.is_file() else self.root / "package.json"

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

    @staticmethod
    def clip(line: str) -> str:
        return re.sub(r"\s+", " ", line.strip())[:300]

    def scan_styles(self) -> None:
        keyframe_re = re.compile(r"@(?:-webkit-)?keyframes\s+([\w-]+)", re.I)
        transition_re = re.compile(r"\btransition(?:-property|-duration|-timing-function|-delay)?\s*:\s*([^;{}]+)", re.I)
        animation_re = re.compile(r"\banimation(?:-name|-duration|-iteration-count|-timing-function|-delay|-fill-mode|-play-state|-timeline)?\s*:\s*([^;{}]+)", re.I)
        view_name_re = re.compile(r"\bview-transition-name\s*:\s*([\w-]+)", re.I)

        for path, text in self.contents.items():
            if path.suffix.lower() not in STYLE_EXTENSIONS:
                continue
            lower = text.lower()
            if "prefers-reduced-motion" in lower:
                self.reduced_motion_files.add(path)
                self.inventory.reduced_motion_signals += lower.count("prefers-reduced-motion")
            if "@media (prefers-reduced-motion: reduce)" in lower or "@media(prefers-reduced-motion:reduce)" in lower:
                pass

            for match in keyframe_re.finditer(text):
                name = match.group(1)
                line = self.line_number(text, match.start())
                self.keyframes[name].append((path, line))
                self.inventory.keyframes += 1
                self.motion_sources += 1

            for match in transition_re.finditer(text):
                value = match.group(1).strip()
                line = self.line_number(text, match.start())
                self.inventory.transitions += 1
                self.motion_sources += 1
                lower_value = value.lower()
                if re.search(r"(^|[\s,])all($|[\s,])", lower_value):
                    self.add(
                        "CSS001", "high", "high", "css", path, line,
                        "Broad transition uses all properties",
                        self.clip(text.splitlines()[line - 1] if line <= len(text.splitlines()) else value),
                        "Unexpected property changes may animate layout, create delays, or interfere with focus and reduced-motion behavior.",
                        "List only the intended transition properties and define duration and easing through semantic tokens.",
                    )
                props = {p.strip().split()[0] for p in lower_value.split(",") if p.strip()}
                layout = sorted(props.intersection(LAYOUT_PROPERTIES))
                if layout:
                    self.add(
                        "CSS002", "medium", "medium", "performance", path, line,
                        "Transition includes layout-affecting properties",
                        ", ".join(layout),
                        "Animating layout can increase style, layout, and paint work and may produce unstable interaction latency.",
                        "Measure the actual effect; consider transform-based visual adaptation only when semantics and rendering remain correct.",
                        "Record a runtime trace on representative devices before deciding whether migration is needed.",
                    )

            for match in animation_re.finditer(text):
                value = match.group(1).strip()
                line = self.line_number(text, match.start())
                self.inventory.css_animations += 1
                self.motion_sources += 1
                if re.search(r"\binfinite\b", value, re.I):
                    self.add(
                        "CSS003", "high", "high", "accessibility", path, line,
                        "Infinite animation detected",
                        self.clip(text.splitlines()[line - 1] if line <= len(text.splitlines()) else value),
                        "Continuous motion can distract users, trigger vestibular symptoms, consume resources, and require pause or stop behavior.",
                        "Document the purpose, stop when offscreen or complete, add pause controls when required, and provide a reduced-motion substitute.",
                    )
                duration_match = re.search(r"(?<![-\w])(\d+(?:\.\d+)?)(ms|s)\b", value, re.I)
                if duration_match:
                    amount = float(duration_match.group(1))
                    ms = amount if duration_match.group(2).lower() == "ms" else amount * 1000
                    if ms >= 5000:
                        self.add(
                            "CSS004", "medium", "medium", "accessibility", path, line,
                            "Long-running animation detected",
                            f"Approximate declared duration: {ms:.0f} ms; {self.clip(value)}",
                            "Long automatic motion may require pause, stop, or hide controls and can delay frequent interaction.",
                            "Confirm whether it starts automatically, persists with other content, and needs explicit user control.",
                        )

            for match in view_name_re.finditer(text):
                name = match.group(1)
                line = self.line_number(text, match.start())
                self.view_names[name].append((path, line))
                self.inventory.view_transition_signals += 1
                self.motion_sources += 1

            checks = [
                (r"\bscroll-behavior\s*:\s*smooth\b", "CSS005", "medium", "Smooth scrolling requires a reduced-motion strategy", "Smooth scrolling can cause discomfort and must not be the only way to locate content.", "Override to auto under reduced motion and preserve immediate focus or anchor positioning."),
                (r"\btouch-action\s*:\s*none\b", "CSS006", "high", "Global or component touch-action none detected", "This can suppress browser scrolling, pinch zoom, history gestures, or assistive interaction.", "Narrow touch-action to the direct-manipulation surface and preserve required browser gestures."),
                (r"\bwill-change\s*:\s*(?!auto|initial|inherit|unset)[^;{}]+", "CSS007", "medium", "Persistent will-change hint detected", "Long-lived layer promotion can increase memory, rasterization, and battery use.", "Apply will-change shortly before a measured transition and remove it afterward."),
                (r"\banimation-play-state\s*:\s*paused\b", "CSS008", "low", "CSS animation pause state exists", "A pause style is useful only if users can operate an accessible pause control and state stays synchronized.", "Verify the control name, keyboard behavior, persistence, and resume semantics.", "Confirm a user-facing pause mechanism exists."),
                (r"\b(?:transform|translate|scale)\s*:[^;{}]*(?:translatez|perspective|rotate[xy]|scale\s*\(|scale3d)", "CSS009", "medium", "Potential vestibular motion effect detected", "Large scale, depth, perspective, or rotation effects can trigger vestibular discomfort.", "Classify the effect and replace large-field motion under reduced motion with an instant change or restrained fade."),
                (r"\banimation-timeline\s*:\s*(?!auto|none)[^;{}]+", "CSS010", "medium", "Scroll-driven animation detected", "Scroll-linked motion can create parallax or continuous movement and requires progressive enhancement.", "Ensure content remains available without animation and disable or simplify under reduced motion."),
            ]
            for pattern, rule, severity, title, impact, remediation, *manual in checks:
                for match in re.finditer(pattern, text, re.I):
                    line = self.line_number(text, match.start())
                    self.motion_sources += 1
                    self.add(rule, severity, "medium", "css", path, line, title, self.clip(match.group(0)), impact, remediation, manual[0] if manual else "")

            if re.search(r"prefers-reduced-motion\s*:\s*reduce", text, re.I):
                global_hack = re.search(
                    r"prefers-reduced-motion\s*:\s*reduce[\s\S]{0,1200}?\*[^{}]*\{[^{}]*(?:animation-duration\s*:\s*0\.0+1ms|transition-duration\s*:\s*0\.0+1ms)",
                    text,
                    re.I,
                )
                if global_hack:
                    line = self.line_number(text, global_hack.start())
                    self.add(
                        "CSS011", "medium", "high", "accessibility", path, line,
                        "Global near-zero-duration reduced-motion hack detected",
                        self.clip(global_hack.group(0)),
                        "A global timing override can break lifecycle callbacks, preserve unsafe movement, and remove useful feedback without intentional substitutes.",
                        "Define reduced-motion behavior per motion category or component and keep semantic state independent from animation events.",
                    )

        for name, locations in sorted(self.keyframes.items()):
            if len(locations) > 1:
                path, line = locations[0]
                locs = ", ".join(f"{self.rel(p)}:{ln}" for p, ln in locations[:8])
                self.add(
                    "CSS012", "medium", "high", "architecture", path, line,
                    f"Duplicate keyframe name: {name}",
                    locs,
                    "Global keyframe name collisions can change behavior by load order and make reduced-motion overrides unreliable.",
                    "Namespace keyframes or scope ownership through the build system and remove duplicate definitions.",
                )

        for name, locations in sorted(self.view_names.items()):
            if name.lower() != "none" and len(locations) > 1:
                path, line = locations[0]
                locs = ", ".join(f"{self.rel(p)}:{ln}" for p, ln in locations[:8])
                self.add(
                    "CSS013", "high", "medium", "view-transitions", path, line,
                    f"Repeated view-transition-name may collide: {name}",
                    locs,
                    "Two simultaneously rendered elements with the same transition name can make the transition invalid or unpredictable.",
                    "Generate stable unique names per live element and remove names when elements are not participating.",
                    "Confirm whether the selectors can match concurrently at runtime.",
                )

    def scan_markup(self) -> None:
        for path, text in self.contents.items():
            if path.suffix.lower() not in MARKUP_EXTENSIONS:
                continue
            lower = text.lower()
            if re.search(r"\b(?:pause|stop)\b", lower) and re.search(r"\b(?:carousel|animation|motion|autoplay|ticker)\b", lower):
                self.pause_control_signals += 1
            if re.search(r"\b(?:move up|move down|move to|position|destination)\b", lower):
                self.drag_alternative_signals += 1

            patterns = [
                (r"\bdraggable\s*=\s*[\"']?true", "HTML001", "medium", "Native draggable content detected", "Native drag-and-drop is not a complete cross-input sortable pattern.", "Provide keyboard operation and a single-pointer non-drag alternative; verify touch and screen-reader behavior."),
                (r"\bon(?:dragstart|dragover|drop|dragend)\s*=", "HTML002", "medium", "Inline drag event handler detected", "Inline drag handlers often lack cancellation, input alternatives, and lifecycle cleanup.", "Move behavior to an owned component and document keyboard, pointer, cancellation, and announcement contracts."),
                (r"\bautoplay\b", "HTML003", "high", "Autoplaying media or carousel signal detected", "Automatic moving content may distract users and can require pause, stop, hide, or audio controls.", "Disable autoplay by default for task content or provide an immediately available accessible pause control and reduced-motion behavior."),
                (r"<(?:marquee|blink)\b", "HTML004", "critical", "Obsolete moving or blinking element detected", "Obsolete automatic motion can be inaccessible, unpredictable, and impossible to control consistently.", "Replace with static content or an accessible component with explicit user control."),
                (r"\bdata-(?:ride|bs-ride)\s*=\s*[\"']carousel[\"']", "HTML005", "high", "Auto-starting Bootstrap carousel detected", "Auto-advancing slides can move focus context, distract users, and require explicit pause behavior.", "Disable automatic cycling or implement accessible pause, position, and reduced-motion behavior."),
                (r"\bon(?:touchstart|touchmove|mousedown|mousemove)\s*=", "HTML006", "medium", "Input-specific inline handler detected", "Mouse- or touch-specific handlers can exclude pen, keyboard, or concurrent input methods.", "Use semantic controls or Pointer Events and preserve keyboard and single-pointer alternatives."),
            ]
            for pattern, rule, severity, title, impact, remediation in patterns:
                for match in re.finditer(pattern, text, re.I):
                    line = self.line_number(text, match.start())
                    if "drag" in pattern:
                        self.inventory.drag_signals += 1
                    self.add(rule, severity, "high", "markup", path, line, title, self.clip(match.group(0)), impact, remediation)

    def scan_scripts(self) -> None:
        for path, text in self.contents.items():
            if path.suffix.lower() not in SCRIPT_EXTENSIONS:
                continue
            lower = text.lower()
            lines = text.splitlines()
            if "prefers-reduced-motion" in lower or "reducedmotion" in lower or "reduced_motion" in lower:
                self.reduced_motion_files.add(path)
                self.inventory.reduced_motion_signals += max(1, lower.count("prefers-reduced-motion"))
            if re.search(r"\b(?:pause|stop)\s*(?:\(|:)", lower):
                self.pause_control_signals += 1
            if re.search(r"\b(?:moveup|movedown|moveto|destination|position)\b", lower):
                self.drag_alternative_signals += 1
            if re.search(r"\b(?:playwright|cypress|vitest|jest|webdriver|selenium)\b", lower):
                self.test_signals += 1

            self.inventory.waapi_calls += len(re.findall(r"\.animate\s*\(", text))
            self.inventory.raf_calls += len(re.findall(r"\brequestAnimationFrame\s*\(", text))
            self.inventory.view_transition_signals += len(re.findall(r"\bstartViewTransition\s*\(", text))
            self.inventory.gesture_handlers += len(re.findall(r"\b(?:pointerdown|pointermove|touchstart|touchmove|gesturestart|wheel)\b", text, re.I))
            self.inventory.drag_signals += len(re.findall(r"\b(?:dragstart|dragover|drop|sortable|dnd|draggable)\b", text, re.I))
            self.motion_sources += self.inventory.waapi_calls + self.inventory.raf_calls

            patterns = [
                (r"\bsetInterval\s*\(", "JS001", "medium", "Timer loop detected", "Interval-based animation can drift, continue while hidden, and ignore frame scheduling.", "Use requestAnimationFrame for visual frame work or a state timer for non-visual work; always clear it on teardown."),
                (r"\brequestAnimationFrame\s*\(", "JS002", "medium", "requestAnimationFrame loop or callback detected", "Frame callbacks require explicit stop conditions, visibility behavior, timestamp-based progress, and cleanup.", "Verify cancellation, detached-node behavior, reduced-motion handling, and background-tab recovery.", "Confirm every recurring frame request has a reachable cancel or completion path."),
                (r"\.animate\s*\(", "JS003", "medium", "Web Animations or library animate call detected", "Runtime animations can outlive components or couple semantic state to completion promises.", "Classify the API, handle cancellation, keep final state independent, and apply the reduced-motion contract."),
                (r"\bdocument\.startViewTransition\s*\(", "JS004", "medium", "View Transition API call detected", "View transitions require support fallback, unique naming, focus/history behavior, and reduced-motion treatment.", "Wrap as progressive enhancement and ensure navigation or state updates remain correct without animation."),
                (r"\b(?:pointerdown|mousedown|touchstart)\b[\s\S]{0,180}?\b(?:delete|remove|submit|navigate|location\.|window\.open|fetch\s*\()", "JS005", "high", "Potential action completion on pointer down", "Completing ordinary actions on a down-event increases accidental activation risk and can violate pointer-cancellation expectations.", "Complete on click or pointer up, or provide abort/undo when down-event activation is genuinely essential.", "Confirm the matched action is executed by the down handler rather than nearby unrelated code."),
                (r"addEventListener\s*\(\s*[\"'](?:touchmove|wheel)[\"'][\s\S]{0,260}?preventDefault\s*\(", "JS006", "high", "Wheel or touch movement is cancelled", "Cancelling broad movement handlers can block scrolling, zoom, history gestures, or assistive interaction.", "Limit cancellation to an owned direct-manipulation surface and provide browser-compatible alternatives."),
                (r"addEventListener\s*\(\s*[\"'](?:touchmove|wheel)[\"'][\s\S]{0,220}?passive\s*:\s*false", "JS007", "medium", "Non-passive scroll-related listener detected", "Non-passive listeners can delay scrolling and are often paired with gesture hijacking.", "Use passive listeners unless cancellation is required; narrow the target and measure interaction latency."),
                (r"\b(?:deviceorientation|devicemotion)\b", "JS008", "high", "Device motion actuation detected", "Device movement cannot be the sole way to operate functionality and accidental actuation must be preventable.", "Provide conventional UI controls and a setting to disable motion response unless the motion is essential."),
                (r"\b(?:gesturestart|gesturechange|gestureend)\b", "JS009", "high", "Multipoint gesture handler detected", "Path-based or multipoint gestures require a single-pointer non-path alternative unless essential.", "Add explicit buttons, inputs, or selection workflows for the same operation."),
                (r"\b(?:touchstart|touchmove|touchend)\b", "JS010", "medium", "Touch-specific interaction detected", "Touch-only code can exclude mouse, pen, keyboard, and concurrent input changes.", "Prefer semantic controls or Pointer Events and test all supported input modalities."),
                (r"\b(?:mousedown|mousemove|mouseup)\b", "JS011", "low", "Mouse-specific interaction detected", "Mouse-specific direct manipulation may not support touch or pen.", "Prefer Pointer Events when a unified direct-manipulation model is appropriate."),
                (r"\b(?:ondragstart|dragstart|dragover|drop)\b", "JS012", "medium", "Drag-and-drop behavior detected", "Dragging requires a single-pointer non-drag alternative and independently accessible keyboard behavior.", "Add select-then-destination, move controls, or another non-drag pointer workflow and announce results."),
                (r"\b(?:innerWidth|innerHeight|getBoundingClientRect|offsetWidth|offsetHeight|scrollTop|scrollLeft)\b[\s\S]{0,120}?\b(?:style\.|classList\.|setAttribute\s*\()", "JS013", "medium", "Potential layout read followed by write", "Alternating layout reads and writes in animation paths can trigger repeated synchronous layout.", "Batch reads before writes and verify the hot path in a performance trace."),
                (r"\b(?:scroll|wheel|pointermove|mousemove|touchmove)\b[\s\S]{0,180}?\b(?:getBoundingClientRect|offsetWidth|offsetHeight|scrollTop)\b", "JS014", "medium", "Layout measurement in high-frequency handler detected", "High-frequency synchronous measurement can degrade input response and frame stability.", "Cache stable geometry, schedule work, and measure with representative input rates."),
                (r"\b(?:eval|new\s+Function)\s*\(", "JS015", "high", "Dynamic code execution detected near frontend behavior", "Dynamic execution increases security and maintainability risk and can hide animation or gesture behavior from analysis.", "Replace with explicit functions and review any third-party interaction code that depends on it."),
                (r"\$\([^\n]+\)\.animate\s*\(", "JS016", "medium", "jQuery animate usage detected", "Legacy effect queues and callback semantics can complicate interruption, cleanup, and reduced-motion behavior.", "Isolate the component, characterize final state and callbacks, then migrate to CSS or WAAPI where justified."),
                (r"\b(?:scrollTo|scrollBy)\s*\([^)]*behavior\s*:\s*[\"']smooth[\"']", "JS017", "medium", "Programmatic smooth scrolling detected", "Smooth scrolling needs a reduced-motion fallback and must not delay focus or target availability.", "Use instant scrolling under reduced motion and move focus according to semantics independently."),
                (r"\b(?:autoplay|autoPlay|loop)\s*[:=]\s*true\b", "JS018", "high", "Automatic or looping motion enabled in configuration", "Persistent automatic movement may require user controls and can violate reduced-motion expectations.", "Disable by default for task content or provide pause/stop and an explicit reduced-motion configuration."),
            ]
            for pattern, rule, severity, title, impact, remediation, *manual in patterns:
                for match in re.finditer(pattern, text, re.I):
                    line = self.line_number(text, match.start())
                    evidence_line = lines[line - 1] if line <= len(lines) else match.group(0)
                    self.add(rule, severity, "medium", "javascript", path, line, title, self.clip(evidence_line), impact, remediation, manual[0] if manual else "")

            if "requestanimationframe" in lower and "cancelanimationframe" not in lower:
                idx = lower.find("requestanimationframe")
                self.add(
                    "JS019", "high", "medium", "lifecycle", path, self.line_number(text, idx),
                    "requestAnimationFrame is used without cancelAnimationFrame in the same file",
                    self.clip(lines[self.line_number(text, idx) - 1]),
                    "Recurring callbacks can continue after completion, unmount, route changes, or preference changes.",
                    "Store the frame ID, define completion, cancel during teardown, and stop when the document or component is inactive.",
                    "A shared abstraction may cancel the loop elsewhere; verify ownership before changing code.",
                )

            if re.search(r"addEventListener\s*\(\s*[\"'](?:pointer|touch|mouse|wheel|scroll)", text, re.I) and "removeEventListener" not in text:
                idx = re.search(r"addEventListener\s*\(\s*[\"'](?:pointer|touch|mouse|wheel|scroll)", text, re.I)
                assert idx is not None
                self.add(
                    "JS020", "medium", "medium", "lifecycle", path, self.line_number(text, idx.start()),
                    "Interaction listener has no local removal signal",
                    self.clip(lines[self.line_number(text, idx.start()) - 1]),
                    "Listeners can duplicate across mounts or retain detached objects.",
                    "Use stable handler references and remove listeners on teardown or use AbortSignal ownership.",
                    "A framework or shared utility may own cleanup elsewhere; verify actual lifecycle.",
                )

            if re.search(r"setPointerCapture\s*\(", text) and not re.search(r"releasePointerCapture\s*\(|lostpointercapture|pointercancel", text, re.I):
                idx = re.search(r"setPointerCapture\s*\(", text)
                assert idx is not None
                self.add(
                    "JS021", "high", "high", "pointer", path, self.line_number(text, idx.start()),
                    "Pointer capture lacks visible release or cancellation handling",
                    self.clip(lines[self.line_number(text, idx.start()) - 1]),
                    "Lost or stale capture can leave direct manipulation stuck after cancellation, route changes, or teardown.",
                    "Handle pointerup, pointercancel, lostpointercapture, teardown, and explicit cancellation; release capture safely.",
                )

            if re.search(r"startViewTransition\s*\(", text) and not re.search(r"prefers-reduced-motion|reducedMotion|reduced_motion", text, re.I):
                idx = re.search(r"startViewTransition\s*\(", text)
                assert idx is not None
                self.add(
                    "JS022", "high", "medium", "view-transitions", path, self.line_number(text, idx.start()),
                    "View transition has no local reduced-motion signal",
                    self.clip(lines[self.line_number(text, idx.start()) - 1]),
                    "Snapshot-based page or state motion can produce large-field movement for users requesting reduced motion.",
                    "Route through a central motion preference and skip or simplify the transition under reduced motion.",
                    "A shared CSS or application-level policy may provide the override; verify the complete path.",
                )

            effect_blocks = list(re.finditer(r"\buseEffect\s*\(\s*\(\s*\)\s*=>\s*\{([\s\S]{0,1800}?)\}\s*,", text))
            for effect in effect_blocks:
                body = effect.group(1)
                if re.search(r"requestAnimationFrame|setInterval|addEventListener|\.animate\s*\(", body) and not re.search(r"\breturn\s*\(\s*\)\s*=>|\breturn\s+function", body):
                    line = self.line_number(text, effect.start())
                    self.add(
                        "JS023", "high", "medium", "framework-lifecycle", path, line,
                        "React effect creates motion resources without visible cleanup",
                        self.clip(lines[line - 1]),
                        "Strict or repeated lifecycle execution can duplicate timers, listeners, and animations or update unmounted state.",
                        "Return a cleanup function that cancels animations and timers and removes listeners; keep semantic state independent.",
                        "Confirm cleanup is not delegated to a called abstraction.",
                    )

            if re.search(r"\b(?:onAnimationEnd|animationend|transitionend)\b", text) and re.search(r"\b(?:setState|dispatch|remove|delete|navigate|focus\s*\()", text):
                idx = re.search(r"\b(?:onAnimationEnd|animationend|transitionend)\b", text)
                assert idx is not None
                self.add(
                    "JS024", "high", "medium", "state", path, self.line_number(text, idx.start()),
                    "Application behavior may depend on animation completion",
                    self.clip(lines[self.line_number(text, idx.start()) - 1]),
                    "Completion events may be skipped by cancellation, removal, reduced duration, navigation, or unsupported animation.",
                    "Apply semantic state through the interaction contract and use animation completion only for optional visual cleanup.",
                    "Inspect the matched callback to determine whether essential state truly depends on the event.",
                )

    def project_level_checks(self) -> None:
        anchor = next(iter(self.contents), self.root if self.root.is_file() else self.root / "README.md")
        if self.motion_sources > 0 and self.inventory.reduced_motion_signals == 0:
            self.add(
                "PRJ001", "high", "high", "accessibility", anchor, 1,
                "Motion sources exist without a detectable reduced-motion strategy",
                f"Detected motion sources: {self.motion_sources}",
                "Users requesting reduced motion may still receive all transitions, keyframes, gestures, and navigation motion.",
                "Define a central reduced-motion contract and component-specific substitutions; verify runtime libraries and media.",
                "Static analysis cannot see design-system defaults or remote styles; inspect the rendered application.",
            )
        if any(f.rule_id in {"CSS003", "HTML003", "HTML005", "JS018"} for f in self.findings) and self.pause_control_signals == 0:
            self.add(
                "PRJ002", "high", "medium", "accessibility", anchor, 1,
                "Automatic or continuous motion lacks a detectable pause or stop control",
                "Automatic or looping motion findings exist, but no likely pause/stop implementation was found.",
                "Persistent moving or auto-updating content may distract users and can require an accessible pause, stop, hide, or frequency control.",
                "Add explicit user control, preserve task usability while paused, and verify the five-second and auto-update conditions.",
            )
        if self.inventory.drag_signals > 0 and self.drag_alternative_signals == 0:
            self.add(
                "PRJ003", "high", "medium", "input-accessibility", anchor, 1,
                "Dragging exists without a detectable non-drag alternative",
                f"Drag-related signals: {self.inventory.drag_signals}",
                "Keyboard support alone does not provide the required single-pointer non-drag alternative for users who cannot drag.",
                "Provide select-then-destination, move controls, a destination menu, numeric ordering, or another equivalent pointer workflow.",
                "Static analysis cannot reliably identify labels or server-rendered alternatives; test the complete workflow.",
            )
        if self.motion_sources > 0 and self.test_signals == 0:
            self.add(
                "PRJ004", "low", "medium", "testing", anchor, 1,
                "No interaction or motion test tooling was detected",
                "No obvious Playwright, Cypress, Jest, Vitest, WebDriver, or Selenium signal was found.",
                "Animation lifecycle, final state, cancellation, reduced motion, and input alternatives can regress silently.",
                "Add representative component and end-to-end tests; verify final states rather than brittle frame timing.",
            )

    def run(self) -> tuple[Inventory, list[Finding]]:
        self.load()
        self.scan_packages()
        self.scan_styles()
        self.scan_markup()
        self.scan_scripts()
        self.project_level_checks()
        unique: dict[tuple[str, str, int, str], Finding] = {}
        for finding in self.findings:
            key = (finding.rule_id, finding.file, finding.line, finding.evidence)
            unique[key] = finding
        self.findings = sorted(
            unique.values(),
            key=lambda f: (-SEVERITY_RANK[f.severity], f.file, f.line, f.rule_id),
        )
        return self.inventory, self.findings


def severity_summary(findings: Sequence[Finding]) -> dict[str, int]:
    counts = Counter(f.severity for f in findings)
    return {severity: counts.get(severity, 0) for severity in ("critical", "high", "medium", "low", "info")}


def markdown_report(inventory: Inventory, findings: Sequence[Finding]) -> str:
    counts = severity_summary(findings)
    lines = [
        "# Interaction and Motion Static Review",
        "",
        "> Static evidence only. Confirm behavior in a browser with keyboard, pointer, touch, pen, reduced motion, assistive technology, and runtime traces.",
        "",
        "## Summary",
        "",
        f"- Root: `{inventory.root}`",
        f"- Files scanned: {inventory.files_scanned}",
        f"- Bytes scanned: {inventory.bytes_scanned}",
        f"- Findings: {len(findings)}",
        f"- Severity: Critical {counts['critical']}, High {counts['high']}, Medium {counts['medium']}, Low {counts['low']}, Info {counts['info']}",
        "",
        "## Inventory",
        "",
        f"- Style files: {inventory.style_files}",
        f"- Markup files: {inventory.markup_files}",
        f"- Script files: {inventory.script_files}",
        f"- Keyframes: {inventory.keyframes}",
        f"- Transition declarations: {inventory.transitions}",
        f"- CSS animation declarations: {inventory.css_animations}",
        f"- WAAPI or animate calls: {inventory.waapi_calls}",
        f"- requestAnimationFrame calls: {inventory.raf_calls}",
        f"- Gesture-handler signals: {inventory.gesture_handlers}",
        f"- Drag signals: {inventory.drag_signals}",
        f"- View Transition signals: {inventory.view_transition_signals}",
        f"- Reduced-motion signals: {inventory.reduced_motion_signals}",
        f"- Frameworks: {', '.join(inventory.frameworks or []) or 'none detected'}",
        f"- Interaction libraries: {', '.join(inventory.libraries or []) or 'none detected'}",
        "",
        "## Findings",
        "",
    ]
    if not findings:
        lines.append("No static findings were produced. Manual and runtime testing are still required.")
    for index, finding in enumerate(findings, start=1):
        lines.extend(
            [
                f"### {index}. [{finding.severity.upper()}] {finding.title}",
                "",
                f"- Rule: `{finding.rule_id}`",
                f"- Confidence: {finding.confidence}",
                f"- Area: {finding.area}",
                f"- Location: `{finding.file}:{finding.line}`",
                f"- Evidence: `{finding.evidence.replace('`', "'")}`",
                f"- Impact: {finding.impact}",
                f"- Remediation: {finding.remediation}",
            ]
        )
        if finding.manual_check:
            lines.append(f"- Manual check: {finding.manual_check}")
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def json_report(inventory: Inventory, findings: Sequence[Finding]) -> str:
    payload = {
        "schema_version": 1,
        "tool": "inspect_interaction_motion.py",
        "disclaimer": "Static evidence only; this report does not prove accessibility conformance, runtime performance, or browser support.",
        "summary": {
            "findings": len(findings),
            "severity": severity_summary(findings),
        },
        "inventory": asdict(inventory),
        "findings": [asdict(f) for f in findings],
    }
    return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"


def should_fail(findings: Sequence[Finding], threshold: str | None) -> bool:
    if not threshold:
        return False
    rank = SEVERITY_RANK[threshold]
    return any(SEVERITY_RANK[f.severity] >= rank for f in findings)


def parse_args(argv: Sequence[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect web interaction and motion architecture.")
    parser.add_argument("path", type=Path, help="Project directory or source file to scan")
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--output", type=Path, help="Write the report to this file")
    parser.add_argument("--fail-on", choices=("critical", "high", "medium", "low", "info"))
    parser.add_argument("--max-file-bytes", type=int, default=2_000_000)
    return parser.parse_args(argv)


def main(argv: Sequence[str] | None = None) -> int:
    args = parse_args(argv or sys.argv[1:])
    if not args.path.exists():
        print(f"error: path does not exist: {args.path}", file=sys.stderr)
        return 1
    scanner = Scanner(args.path, args.output, max_bytes=args.max_file_bytes)
    inventory, findings = scanner.run()
    report = markdown_report(inventory, findings) if args.format == "markdown" else json_report(inventory, findings)
    if args.output:
        args.output.parent.mkdir(parents=True, exist_ok=True)
        args.output.write_text(report, encoding="utf-8")
    else:
        sys.stdout.write(report)
    return 2 if should_fail(findings, args.fail_on) else 0


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