#!/usr/bin/env python3
"""Static frontend UI review helper.

This scanner collects evidence and review leads. It does not prove runtime behavior,
accessibility conformance, browser compatibility, or field performance.
"""

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

TEXT_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".cfm", ".cfml", ".php", ".jsp", ".jspx",
    ".aspx", ".ascx", ".cshtml", ".razor", ".vue", ".svelte", ".jsx", ".tsx",
    ".js", ".mjs", ".cjs", ".ts", ".css", ".scss", ".sass", ".less", ".json",
}
MARKUP_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".cfm", ".cfml", ".php", ".jsp", ".jspx",
    ".aspx", ".ascx", ".cshtml", ".razor", ".vue", ".svelte", ".jsx", ".tsx",
}
STYLE_EXTENSIONS = {".css", ".scss", ".sass", ".less", ".vue", ".svelte"}
SCRIPT_EXTENSIONS = {".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx", ".vue", ".svelte"}
SKIP_DIRS = {
    ".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "coverage",
    ".next", ".nuxt", ".output", "out", "target", "bin", "obj", ".cache", ".idea",
    ".vscode", "packages-lock", "bower_components",
}
MAX_FILE_BYTES = 2_000_000
SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "note": 0}


@dataclass
class Finding:
    rule_id: str
    title: str
    severity: str
    confidence: str
    area: str
    file: str
    line: int | None
    evidence: str
    impact: str
    remediation: str
    manual_check: str | None = None


@dataclass
class Inventory:
    files_scanned: int = 0
    bytes_scanned: int = 0
    extensions: dict[str, int] | None = None
    frameworks: list[str] | None = None
    ui_libraries: list[str] | None = None
    build_tools: list[str] | None = None
    server_templates: list[str] | None = None
    test_tools: list[str] | None = None


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect frontend source for UI review signals")
    parser.add_argument("path", help="Project directory or file")
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--output", help="Write output to this file")
    parser.add_argument(
        "--fail-on", choices=("critical", "high", "medium", "low"),
        help="Exit with status 2 when a finding at or above this severity exists",
    )
    parser.add_argument("--include-generated", action="store_true", help="Include common generated directories")
    return parser.parse_args()


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

    for current, dirs, files in os.walk(root):
        if not include_generated:
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
        for name in files:
            path = Path(current) / name
            if path.suffix.lower() not in TEXT_EXTENSIONS:
                continue
            try:
                if path.stat().st_size > MAX_FILE_BYTES:
                    continue
            except OSError:
                continue
            yield path


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


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


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


def add_match(
    findings: list[Finding], *, rule_id: str, title: str, severity: str, confidence: str,
    area: str, rel: str, text: str, match: re.Match[str], impact: str, remediation: str,
    manual_check: str | None = None,
) -> None:
    findings.append(Finding(
        rule_id=rule_id,
        title=title,
        severity=severity,
        confidence=confidence,
        area=area,
        file=rel,
        line=line_number(text, match.start()),
        evidence=compact(match.group(0)),
        impact=impact,
        remediation=remediation,
        manual_check=manual_check,
    ))


def parse_package_json(root: Path) -> tuple[dict[str, str], dict[str, str], list[str]]:
    dependencies: dict[str, str] = {}
    dev_dependencies: dict[str, str] = {}
    notes: list[str] = []
    candidates = [root / "package.json"] if root.is_dir() else []
    if root.is_dir():
        candidates.extend(p for p in root.glob("*/package.json") if p.parent.name not in SKIP_DIRS)
    for path in candidates:
        if not path.exists():
            continue
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            notes.append(f"Could not parse {path}: {exc}")
            continue
        for key, target in (("dependencies", dependencies), ("devDependencies", dev_dependencies)):
            values = data.get(key, {})
            if isinstance(values, dict):
                for name, version in values.items():
                    target[str(name)] = str(version)
    return dependencies, dev_dependencies, notes


def detect_inventory(root: Path, files: list[Path], contents: dict[Path, str]) -> tuple[Inventory, list[str]]:
    dependencies, dev_dependencies, notes = parse_package_json(root if root.is_dir() else root.parent)
    all_deps = {**dependencies, **dev_dependencies}
    corpus = "\n".join(contents.values())[:15_000_000]
    frameworks: set[str] = set()
    ui: set[str] = set()
    build: set[str] = set()
    tests: set[str] = set()
    server: set[str] = set()

    dependency_signals = {
        "react": "React", "react-dom": "React", "vue": "Vue", "@angular/core": "Angular",
        "svelte": "Svelte", "preact": "Preact", "jquery": "jQuery", "alpinejs": "Alpine.js",
        "lit": "Lit", "@hotwired/stimulus": "Stimulus",
    }
    for dep, label in dependency_signals.items():
        if dep in all_deps:
            frameworks.add(f"{label} {all_deps[dep]}")

    ui_signals = {
        "bootstrap": "Bootstrap", "@mui/material": "Material UI", "@angular/material": "Angular Material",
        "vuetify": "Vuetify", "ant-design-vue": "Ant Design Vue", "antd": "Ant Design",
        "tailwindcss": "Tailwind CSS", "foundation-sites": "Foundation", "semantic-ui": "Semantic UI",
        "bulma": "Bulma", "@fluentui/react": "Fluent UI", "@chakra-ui/react": "Chakra UI",
    }
    for dep, label in ui_signals.items():
        if dep in all_deps:
            ui.add(f"{label} {all_deps[dep]}")

    build_signals = {
        "vite": "Vite", "webpack": "webpack", "rollup": "Rollup", "parcel": "Parcel",
        "gulp": "Gulp", "grunt": "Grunt", "esbuild": "esbuild", "sass": "Sass",
        "less": "Less", "typescript": "TypeScript",
    }
    for dep, label in build_signals.items():
        if dep in all_deps:
            build.add(f"{label} {all_deps[dep]}")

    test_signals = {
        "playwright": "Playwright", "@playwright/test": "Playwright", "cypress": "Cypress",
        "vitest": "Vitest", "jest": "Jest", "@testing-library/react": "Testing Library",
        "axe-core": "axe-core", "@axe-core/playwright": "axe Playwright",
        "storybook": "Storybook", "@storybook/react": "Storybook",
    }
    for dep, label in test_signals.items():
        if dep in all_deps:
            tests.add(f"{label} {all_deps[dep]}")

    suffixes = {p.suffix.lower() for p in files}
    if ".cfm" in suffixes or ".cfml" in suffixes:
        server.add("ColdFusion")
    if ".cshtml" in suffixes or ".razor" in suffixes or ".aspx" in suffixes:
        server.add("ASP.NET / Razor")
    if ".php" in suffixes:
        server.add("PHP templates")
    if ".jsp" in suffixes or ".jspx" in suffixes:
        server.add("JSP")

    if re.search(r"\bdata-bs-toggle\s*=", corpus):
        ui.add("Bootstrap 5 markup signal")
    if re.search(r"\bdata-toggle\s*=", corpus):
        ui.add("Bootstrap 3/4 markup signal")
    if re.search(r"\b(?:row-fluid|span\d+)\b", corpus):
        ui.add("Bootstrap 2 markup signal")
    if re.search(r"\b(?:ReactDOM\.render|createRoot\s*\()", corpus):
        frameworks.add("React source signal")
    if re.search(r"\bnew\s+Vue\s*\(|\bcreateApp\s*\(", corpus):
        frameworks.add("Vue source signal")
    if re.search(r"\$\s*\([^)]*\)\.(?:modal|tooltip|popover|dropdown|collapse)\s*\(", corpus):
        frameworks.add("jQuery plugin integration signal")

    ext_counts = Counter(p.suffix.lower() for p in files)
    total_bytes = sum(len(contents[p].encode("utf-8", errors="replace")) for p in files if p in contents)
    inventory = Inventory(
        files_scanned=len(contents),
        bytes_scanned=total_bytes,
        extensions=dict(sorted(ext_counts.items())),
        frameworks=sorted(frameworks),
        ui_libraries=sorted(ui),
        build_tools=sorted(build),
        server_templates=sorted(server),
        test_tools=sorted(tests),
    )
    return inventory, notes


def scan_markup(rel: str, text: str, findings: list[Finding]) -> None:
    lower = text.lower()
    is_full_document = "<html" in lower or "<!doctype" in lower

    if is_full_document and not re.search(r"<html\b[^>]*\blang\s*=", text, re.I):
        m = re.search(r"<html\b", text, re.I)
        if m:
            add_match(findings, rule_id="DOC_LANG", title="Document language is not declared",
                      severity="high", confidence="high", area="Accessibility", rel=rel, text=text, match=m,
                      impact="Screen readers and language-aware tools may use incorrect pronunciation and rules.",
                      remediation="Set the primary document language on the html element and update language changes in content.")

    if is_full_document and not re.search(r"<title\b[^>]*>\s*[^<]+", text, re.I | re.S):
        m = re.search(r"<head\b|<html\b", text, re.I)
        if m:
            add_match(findings, rule_id="DOC_TITLE", title="Document has no meaningful title",
                      severity="medium", confidence="high", area="Semantics", rel=rel, text=text, match=m,
                      impact="Users cannot reliably identify the page in tabs, history, or assistive technology.",
                      remediation="Provide a concise, route-specific title and update it after client-side navigation.")

    for pattern, rule_id, title, severity, impact, remediation, *manual in [
        (r"<meta\b[^>]*name\s*=\s*['\"]viewport['\"][^>]*(?:user-scalable\s*=\s*no|maximum-scale\s*=\s*1(?:\.0)?)", "VIEWPORT_ZOOM", "Viewport configuration restricts zoom", "high", "Users who need magnification may be unable to enlarge content.", "Remove zoom restrictions and validate layout at high zoom."),
        (r"\btabindex\s*=\s*['\"]?[1-9]\d*", "POSITIVE_TABINDEX", "Positive tabindex overrides natural focus order", "high", "Keyboard focus order can diverge from reading and visual order.", "Use DOM order and tabindex=0 only for genuinely custom focusable controls."),
        (r"<iframe\b(?![^>]*\btitle\s*=)[^>]*>", "IFRAME_TITLE", "Iframe has no title", "medium", "Assistive technology users may not know the embedded frame purpose.", "Add a concise title describing the embedded content or task."),
        (r"<(?:video|audio)\b[^>]*\bautoplay\b", "MEDIA_AUTOPLAY", "Media may autoplay", "medium", "Unexpected media can disrupt comprehension and assistive technology use.", "Avoid autoplay or provide immediate pause, stop, and independent volume control as required."),
        (r"href\s*=\s*['\"]javascript:", "JAVASCRIPT_URL", "Navigation uses a javascript URL", "medium", "The control may fail keyboard, security, history, and progressive-enhancement expectations.", "Use a button for commands or a real URL for navigation, with JavaScript enhancement."),
        (r"<a\b[^>]*href\s*=\s*['\"]#['\"][^>]*>", "EMPTY_FRAGMENT_LINK", "Link targets an empty fragment", "low", "The control may unexpectedly jump the page or expose an unclear navigation contract.", "Use a button for commands or a meaningful fragment/URL for navigation."),
        (r"<div\b[^>]*\brole\s*=\s*['\"]button['\"][^>]*>", "CUSTOM_BUTTON", "Generic element is used as a button", "medium", "Custom controls often miss keyboard activation, focus, disabled behavior, or form semantics.", "Prefer a native button. If custom behavior is unavoidable, verify the complete keyboard and state contract.", "Verify Enter and Space activation, focus visibility, disabled state, and accessible name."),
    ]:
        for m in re.finditer(pattern, text, re.I | re.S):
            add_match(findings, rule_id=rule_id, title=title, severity=severity, confidence="high",
                      area="Accessibility" if rule_id != "JAVASCRIPT_URL" else "Interaction",
                      rel=rel, text=text, match=m, impact=impact, remediation=remediation,
                      manual_check=manual[0] if manual else None)

    for m in re.finditer(r"<img\b[^>]*>", text, re.I | re.S):
        tag = m.group(0)
        if not re.search(r"\balt\s*=", tag, re.I):
            add_match(findings, rule_id="IMG_ALT", title="Image has no alt attribute", severity="high",
                      confidence="high", area="Accessibility", rel=rel, text=text, match=m,
                      impact="The image purpose or decorative status is unavailable to non-visual users.",
                      remediation="Provide task-equivalent alternative text or alt=\"\" for a decorative image.")
        if not (re.search(r"\bwidth\s*=", tag, re.I) and re.search(r"\bheight\s*=", tag, re.I)):
            add_match(findings, rule_id="IMG_INTRINSIC_SIZE", title="Image has no explicit intrinsic dimensions",
                      severity="low", confidence="medium", area="Performance", rel=rel, text=text, match=m,
                      impact="Late image sizing can contribute to layout shift when CSS aspect ratio is not reserved.",
                      remediation="Provide width and height attributes or an equivalent stable aspect-ratio reservation.",
                      manual_check="Confirm computed layout reserves the final media geometry before load.")

    for m in re.finditer(r"<button\b[^>]*>(.*?)</button\s*>", text, re.I | re.S):
        body = re.sub(r"<[^>]+>", "", m.group(1)).strip()
        tag = m.group(0)
        has_name = bool(body or re.search(r"\baria-label(?:ledby)?\s*=|\btitle\s*=", tag, re.I))
        if not has_name:
            add_match(findings, rule_id="BUTTON_NAME", title="Button may have no accessible name", severity="high",
                      confidence="high", area="Accessibility", rel=rel, text=text, match=m,
                      impact="Users of assistive technology cannot determine the action.",
                      remediation="Provide visible button text or a stable programmatic name matching the visible purpose.")

    ids: defaultdict[str, list[int]] = defaultdict(list)
    for m in re.finditer(r"\bid\s*=\s*['\"]([^'\"]+)['\"]", text, re.I):
        ids[m.group(1)].append(line_number(text, m.start()))
    for value, lines in ids.items():
        if len(lines) > 1:
            findings.append(Finding(
                rule_id="DUPLICATE_ID", title=f"Duplicate id '{value}'", severity="high", confidence="confirmed",
                area="Semantics", file=rel, line=lines[0], evidence=f"Lines {', '.join(map(str, lines[:8]))}",
                impact="Labels, ARIA references, fragments, scripts, and tests may resolve to the wrong element.",
                remediation="Generate unique IDs and update every reference to the intended element.",
            ))

    for m in re.finditer(r"\b(?:aria-labelledby|aria-describedby|aria-controls|aria-owns)\s*=\s*['\"]([^'\"]+)['\"]", text, re.I):
        refs = [part for part in re.split(r"\s+", m.group(1).strip()) if part]
        missing = [ref for ref in refs if not re.search(rf"\bid\s*=\s*['\"]{re.escape(ref)}['\"]", text, re.I)]
        if missing and is_full_document:
            add_match(findings, rule_id="ARIA_BROKEN_REF", title="ARIA reference does not resolve in the document",
                      severity="high", confidence="medium", area="Accessibility", rel=rel, text=text, match=m,
                      impact="The intended accessible name, description, relationship, or controlled target may be lost.",
                      remediation=f"Ensure referenced IDs exist and are unique. Missing in this file: {', '.join(missing)}.",
                      manual_check="For component templates, confirm whether referenced IDs are injected by a parent or runtime renderer.")

    for m in re.finditer(r"\bon(?:click|keydown|keyup|keypress|change|submit)\s*=", text, re.I):
        add_match(findings, rule_id="INLINE_HANDLER", title="Inline event handler couples behavior to markup",
                  severity="low", confidence="confirmed", area="Maintainability", rel=rel, text=text, match=m,
                  impact="Inline handlers complicate lifecycle cleanup, content-security policy, testing, and reuse.",
                  remediation="Move behavior to a scoped module or framework component while preserving public hooks.")

    for form_match in re.finditer(r"<form\b[^>]*>(.*?)</form\s*>", text, re.I | re.S):
        form_body = form_match.group(1)
        form_offset = form_match.start(1)
        for button_match in re.finditer(r"<button\b(?![^>]*\btype\s*=)[^>]*>", form_body, re.I | re.S):
            findings.append(Finding(
                rule_id="BUTTON_TYPE", title="Button in a form may rely on implicit submit behavior",
                severity="medium", confidence="medium", area="Interaction",
                file=rel, line=line_number(text, form_offset + button_match.start()),
                evidence=compact(button_match.group(0)),
                impact="A non-submit action can unexpectedly submit the form.",
                remediation="Set type=button for commands and type=submit only for the intended submit action.",
                manual_check="Confirm whether submit is intended for this control.",
            ))

    if is_full_document:
        label_targets = set(re.findall(r"<label\b[^>]*\bfor\s*=\s*['\"]([^'\"]+)['\"]", text, re.I))
        for control_match in re.finditer(r"<(input|select|textarea)\b[^>]*>", text, re.I | re.S):
            tag = control_match.group(0)
            if re.search(r"\btype\s*=\s*['\"]hidden['\"]", tag, re.I):
                continue
            if re.search(r"\baria-label(?:ledby)?\s*=|\btitle\s*=", tag, re.I):
                continue
            id_match = re.search(r"\bid\s*=\s*['\"]([^'\"]+)['\"]", tag, re.I)
            wrapped = bool(re.search(r"<label\b[^>]*>[\s\S]{0,1000}$", text[:control_match.start()], re.I) and re.search(r"^[\s\S]{0,1000}</label\s*>", text[control_match.end():], re.I))
            if (id_match and id_match.group(1) in label_targets) or wrapped:
                continue
            add_match(findings, rule_id="FORM_LABEL", title="Form control may have no programmatic label",
                      severity="high", confidence="medium", area="Accessibility", rel=rel, text=text, match=control_match,
                      impact="Users may not know what data the field expects, especially with assistive technology or speech input.",
                      remediation="Associate a visible label with the control. Keep placeholder and help text separate from the label.",
                      manual_check="Confirm whether a label is generated by a component or runtime template outside this file.")


def scan_styles(rel: str, text: str, findings: list[Finding], project_flags: dict[str, bool]) -> None:
    patterns = [
        (r"(?:outline|outline-style)\s*:\s*(?:none|0)\b", "FOCUS_OUTLINE", "Focus outline is removed", "high", "Keyboard users may lose visible focus indication.", "Replace removal with a visible :focus-visible treatment that works in all themes and forced-colors mode."),
        (r"(?:html|body)[^{]*\{[^}]*overflow(?:-y)?\s*:\s*hidden", "ROOT_SCROLL_LOCK", "Root scrolling is disabled in stylesheet", "medium", "Content can become unreachable at zoom, small viewport heights, or after dynamic expansion.", "Scope scroll locking to an active overlay and restore it reliably on every close path."),
        (r"-webkit-text-size-adjust\s*:\s*none|text-size-adjust\s*:\s*none", "TEXT_SIZE_ADJUST", "Automatic text-size adjustment is disabled", "high", "Mobile users may be unable to obtain readable text sizing.", "Remove the restriction and make the layout resilient to larger text."),
        (r"@import\s+(?:url\()?['\"]?[^;]+", "CSS_IMPORT", "Stylesheet uses CSS @import", "low", "Imported styles can extend the render-blocking request chain and obscure dependency order.", "Prefer build-time composition or explicit link loading when compatible with the project."),
        (r"\bz-index\s*:\s*(?:[1-9]\d{4,})\b", "Z_INDEX_EXTREME", "Very high z-index indicates unmanaged stacking", "low", "Arbitrary stacking values make overlays, sticky UI, and focus visibility fragile.", "Define a small semantic stacking scale and resolve local stacking contexts."),
        (r"\bwidth\s*:\s*(?:1[2-9]\d{2}|[2-9]\d{3,})px\b", "FIXED_WIDE_WIDTH", "Large fixed width may break reflow", "medium", "Content may require page-level horizontal scrolling at narrow viewports or high zoom.", "Use max-width, intrinsic sizing, wrapping, Grid/Flexbox, or contained overflow according to content semantics."),
        (r"\bheight\s*:\s*100vh\b", "STATIC_VH", "Layout relies on 100vh", "low", "Mobile browser chrome and on-screen keyboards can obscure or clip content.", "Evaluate dynamic/small viewport units and content-driven min-height with a compatible fallback."),
    ]
    for pattern, rule_id, title, severity, impact, remediation in patterns:
        for m in re.finditer(pattern, text, re.I | re.S):
            add_match(findings, rule_id=rule_id, title=title, severity=severity,
                      confidence="high" if rule_id in {"FOCUS_OUTLINE", "TEXT_SIZE_ADJUST"} else "medium",
                      area="Accessibility" if rule_id in {"FOCUS_OUTLINE", "TEXT_SIZE_ADJUST"} else "Responsive",
                      rel=rel, text=text, match=m, impact=impact, remediation=remediation,
                      manual_check="Confirm the computed style and whether an equivalent accessible fallback exists." if rule_id == "FOCUS_OUTLINE" else None)

    important_count = len(re.findall(r"!important\b", text, re.I))
    if important_count >= 12:
        findings.append(Finding(
            rule_id="IMPORTANT_DENSITY", title="Stylesheet contains many !important declarations",
            severity="medium", confidence="high", area="Maintainability", file=rel, line=None,
            evidence=f"{important_count} occurrences",
            impact="Cascade overrides become difficult to reason about and increase migration and regression risk.",
            remediation="Inventory override causes, introduce explicit layers or component scopes, and remove declarations incrementally.",
        ))

    project_flags["has_motion"] |= bool(re.search(r"@keyframes\b|\banimation(?:-[\w-]+)?\s*:|\btransition(?:-[\w-]+)?\s*:", text, re.I))
    project_flags["has_reduced_motion"] |= "prefers-reduced-motion" in text
    project_flags["has_forced_colors"] |= "forced-colors" in text
    project_flags["has_container_queries"] |= bool(re.search(r"@container\b|container-type\s*:", text, re.I))
    project_flags["has_media_queries"] |= "@media" in text


def scan_scripts(rel: str, text: str, findings: list[Finding], project_flags: dict[str, bool]) -> None:
    patterns = [
        (r"\beval\s*\(|\bnew\s+Function\s*\(", "DYNAMIC_CODE", "Dynamic code execution requires review", "high", "Dynamic execution increases security, debugging, and content-security-policy risk.", "Replace with explicit parsing or dispatch. If unavoidable, document trusted input and security controls."),
        (r"\.innerHTML\s*=|\binsertAdjacentHTML\s*\(|\bdangerouslySetInnerHTML\b|\bv-html\s*=|\bbypassSecurityTrustHtml\b", "HTML_INJECTION", "HTML injection API is used", "high", "Untrusted content can become scriptable markup; trusted content can still create semantic and lifecycle defects.", "Trace data origin, sanitize with an approved strategy, and prefer structured DOM or framework rendering.", "Determine whether content is trusted and how sanitization is enforced."),
        (r"new\s+XMLHttpRequest\s*\([^)]*\)[\s\S]{0,500}?\.open\s*\([^,]+,[^,]+,\s*false\s*\)", "SYNC_XHR", "Synchronous XMLHttpRequest may block the main thread", "high", "The interface can freeze during network activity.", "Use asynchronous fetch/request handling with pending, timeout, error, and cancellation states."),
        (r"addEventListener\s*\(\s*['\"](?:touchmove|wheel)['\"]", "SCROLL_LISTENER", "Potentially expensive scroll-related listener", "medium", "Main-thread listeners can delay scrolling and interaction when they perform work or prevent defaults.", "Verify handler cost, event frequency, passive behavior, and cleanup.", "Profile the handler and confirm whether preventDefault is required."),
        (r"\bsetInterval\s*\(", "INTERVAL", "Recurring timer requires lifecycle review", "low", "Timers can continue after components or routes are removed and can cause stale updates.", "Store and clear the timer in the owning lifecycle; prefer event-driven updates where possible.", "Confirm cleanup on unmount, route change, modal close, and error paths."),
        (r"\bwindow\.on(?:load|resize|scroll|beforeunload)\s*=", "GLOBAL_HANDLER", "Global event property is assigned directly", "medium", "Assignments can overwrite another owner and complicate teardown.", "Use scoped listeners with explicit registration and cleanup, or a shared event adapter."),
        (r"document\.write\s*\(", "DOCUMENT_WRITE", "document.write is used", "high", "It can block parsing, replace the document, and behave unpredictably after load.", "Replace with explicit DOM construction or controlled server rendering."),
    ]
    for pattern, rule_id, title, severity, impact, remediation, *manual in patterns:
        for m in re.finditer(pattern, text, re.I | re.S):
            add_match(findings, rule_id=rule_id, title=title, severity=severity,
                      confidence="confirmed" if rule_id in {"DYNAMIC_CODE", "DOCUMENT_WRITE"} else "medium",
                      area="Lifecycle" if rule_id not in {"DYNAMIC_CODE", "HTML_INJECTION"} else "Security-sensitive UI",
                      rel=rel, text=text, match=m, impact=impact, remediation=remediation,
                      manual_check=manual[0] if manual else None)

    if re.search(r"addEventListener\s*\(", text) and not re.search(r"removeEventListener\s*\(", text):
        if re.search(r"useEffect\s*\(|connectedCallback\s*\(|mounted\s*\(|componentDidMount\s*\(", text):
            m = re.search(r"addEventListener\s*\(", text)
            if m:
                add_match(findings, rule_id="LISTENER_CLEANUP", title="Component listener may have no matching cleanup",
                          severity="medium", confidence="medium", area="Lifecycle", rel=rel, text=text, match=m,
                          impact="Repeated mounts can duplicate actions, leak memory, or retain stale state.",
                          remediation="Return or implement lifecycle cleanup for listeners, observers, subscriptions, timers, and requests.",
                          manual_check="Trace the owning lifecycle and verify cleanup on every teardown path.")

    project_flags["has_jquery"] |= bool(re.search(r"(?:\bjQuery\b|\$\s*\()", text))
    project_flags["has_bootstrap5_markup"] |= bool(re.search(r"data-bs-(?:toggle|target|dismiss)", text))
    project_flags["has_bootstrap34_markup"] |= bool(re.search(r"data-(?:toggle|target|dismiss)\s*=", text))
    project_flags["has_bootstrap_jquery_plugins"] |= bool(re.search(r"\$\s*\([^)]*\)\.(?:modal|tooltip|popover|dropdown|collapse|tab|carousel)\s*\(", text))


def scan_html_script_loading(rel: str, text: str, findings: list[Finding]) -> None:
    head_match = re.search(r"<head\b[^>]*>(.*?)</head\s*>", text, re.I | re.S)
    if not head_match:
        return
    head = head_match.group(1)
    base_offset = head_match.start(1)
    for m in re.finditer(r"<script\b[^>]*\bsrc\s*=\s*['\"][^'\"]+['\"][^>]*>", head, re.I | re.S):
        tag = m.group(0)
        if not re.search(r"\b(?:defer|async|type\s*=\s*['\"]module['\"])", tag, re.I):
            absolute = re.match(r"[\s\S]*", text[base_offset + m.start():base_offset + m.end()])
            if absolute:
                findings.append(Finding(
                    rule_id="RENDER_BLOCKING_SCRIPT", title="External script in head may block parsing",
                    severity="medium", confidence="medium", area="Performance", file=rel,
                    line=line_number(text, base_offset + m.start()), evidence=compact(tag),
                    impact="Page rendering and interaction readiness can be delayed.",
                    remediation="Use defer or modules when execution ordering permits, or move the script after dependent markup.",
                    manual_check="Measure actual loading behavior and preserve required dependency order.",
                ))


def project_level_findings(root: Path, inventory: Inventory, flags: dict[str, bool], findings: list[Finding]) -> None:
    ui_signals = inventory.ui_libraries or []
    framework_signals = inventory.frameworks or []
    has_bootstrap5 = flags["has_bootstrap5_markup"] or any("Bootstrap 5 markup" in value for value in ui_signals)
    has_bootstrap34 = flags["has_bootstrap34_markup"] or any("Bootstrap 3/4 markup" in value for value in ui_signals)
    has_jquery = flags["has_jquery"] or any(value.startswith("jQuery ") for value in framework_signals)

    if flags["has_motion"] and not flags["has_reduced_motion"]:
        findings.append(Finding(
            rule_id="REDUCED_MOTION_PROJECT", title="Motion is present without a detected reduced-motion strategy",
            severity="medium", confidence="medium", area="Accessibility", file="(project)", line=None,
            evidence="Animations or transitions detected; no prefers-reduced-motion query found in scanned source.",
            impact="Non-essential motion may cause discomfort or make the interface difficult to use.",
            remediation="Provide a project-level reduced-motion policy and component fallbacks that preserve feedback without non-essential movement.",
            manual_check="Confirm whether reduced-motion handling is generated, external, or implemented outside the scanned scope.",
        ))

    if has_bootstrap5 and has_bootstrap34:
        findings.append(Finding(
            rule_id="MIXED_BOOTSTRAP_MARKUP", title="Multiple Bootstrap data-attribute generations are present",
            severity="high", confidence="high", area="Legacy compatibility", file="(project)", line=None,
            evidence="Both data-bs-* and legacy data-toggle/data-target/data-dismiss signals were detected.",
            impact="Components can initialize inconsistently or depend on CSS and JavaScript from different major versions.",
            remediation="Identify ownership per route/component, align CSS and JavaScript majors, and migrate through isolated adapters rather than global replacement.",
        ))

    if has_bootstrap5 and has_jquery and flags["has_bootstrap_jquery_plugins"]:
        findings.append(Finding(
            rule_id="BOOTSTRAP5_JQUERY_PLUGIN", title="Bootstrap 5 markup coexists with jQuery-style Bootstrap plugin calls",
            severity="high", confidence="medium", area="Legacy compatibility", file="(project)", line=None,
            evidence="data-bs-* markup, jQuery, and $(...).modal/tooltip/etc. signals detected.",
            impact="Plugin initialization may target incompatible APIs or duplicate component ownership.",
            remediation="Verify installed versions and replace each plugin call with the matching major API behind a temporary adapter if necessary.",
            manual_check="Confirm whether separate routes intentionally use different Bootstrap majors.",
        ))

    test_tools = inventory.test_tools or []
    if not test_tools and inventory.files_scanned >= 20:
        findings.append(Finding(
            rule_id="NO_TEST_TOOL_SIGNAL", title="No frontend test tooling was detected",
            severity="medium", confidence="medium", area="Testing", file="(project)", line=None,
            evidence="No common unit, browser, visual, or accessibility test dependency found in scanned package metadata.",
            impact="UI migrations and shared-component changes have a higher regression risk.",
            remediation="Add characterization tests for critical journeys and shared primitives before broad remediation.",
            manual_check="Confirm whether tests live in another repository, pipeline, or non-JavaScript toolchain.",
        ))

    if flags["has_media_queries"] and not flags["has_container_queries"]:
        findings.append(Finding(
            rule_id="NO_CONTAINER_QUERY_SIGNAL", title="Responsive rules appear viewport-only",
            severity="note", confidence="medium", area="Responsive", file="(project)", line=None,
            evidence="Media queries detected; no container query signal found.",
            impact="Reusable components may depend on route-specific viewport assumptions.",
            remediation="Evaluate container queries for components that can appear in sidebars, dialogs, dashboards, or variable-width regions.",
            manual_check="This is an architectural review prompt, not a defect when viewport queries are sufficient.",
        ))


def deduplicate(findings: list[Finding]) -> list[Finding]:
    seen: set[tuple[str, str, int | None, str]] = set()
    result: list[Finding] = []
    for finding in findings:
        key = (finding.rule_id, finding.file, finding.line, finding.evidence)
        if key in seen:
            continue
        seen.add(key)
        result.append(finding)
    return sorted(
        result,
        key=lambda item: (-SEVERITY_RANK[item.severity], item.file, item.line or 0, item.rule_id),
    )


def render_markdown(root: Path, inventory: Inventory, findings: list[Finding], notes: list[str]) -> str:
    counts = Counter(f.severity for f in findings)
    areas = Counter(f.area for f in findings)
    lines: list[str] = [
        "# Frontend UI Static Review",
        "",
        f"- Target: `{root}`",
        f"- Files scanned: {inventory.files_scanned}",
        f"- Bytes scanned: {inventory.bytes_scanned}",
        f"- Findings: {len(findings)}",
        "- Limitation: static evidence only; runtime, accessibility, browser, and performance conclusions require manual or automated execution.",
        "",
        "## Severity summary",
        "",
        "| Severity | Count |",
        "| --- | ---: |",
    ]
    for severity in ("critical", "high", "medium", "low", "note"):
        lines.append(f"| {severity.title()} | {counts[severity]} |")

    lines.extend(["", "## Stack inventory", ""])
    inventory_rows = [
        ("Frameworks", inventory.frameworks or []),
        ("UI libraries", inventory.ui_libraries or []),
        ("Build tools", inventory.build_tools or []),
        ("Server templates", inventory.server_templates or []),
        ("Test tools", inventory.test_tools or []),
    ]
    for label, values in inventory_rows:
        lines.append(f"- **{label}:** {', '.join(values) if values else 'No common signal detected'}")
    if inventory.extensions:
        lines.append("- **Extensions:** " + ", ".join(f"`{k}` {v}" for k, v in inventory.extensions.items()))

    if areas:
        lines.extend(["", "## Findings by area", ""])
        for area, count in sorted(areas.items(), key=lambda item: (-item[1], item[0])):
            lines.append(f"- {area}: {count}")

    lines.extend(["", "## Findings", ""])
    if not findings:
        lines.append("No configured static signals were found. This is not a conformance or quality pass.")
    for index, finding in enumerate(findings, 1):
        location = finding.file + (f":{finding.line}" if finding.line else "")
        lines.extend([
            f"### {index}. [{finding.rule_id}] {finding.title}",
            f"- **Severity:** {finding.severity.title()}",
            f"- **Confidence:** {finding.confidence.title()}",
            f"- **Area:** {finding.area}",
            f"- **Location:** `{location}`",
            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("")

    if notes:
        lines.extend(["## Scanner notes", ""])
        lines.extend(f"- {note}" for note in notes)
        lines.append("")

    lines.extend([
        "## Required manual review",
        "",
        "- Render representative routes and complete critical journeys.",
        "- Test keyboard focus, overlays, custom widgets, errors, and asynchronous updates.",
        "- Test zoom, text enlargement, RTL, long content, themes, reduced motion, and forced colors.",
        "- Run browser and assistive-technology checks appropriate to the supported environment.",
        "- Measure performance before and after changes using equivalent lab conditions and field data when available.",
    ])
    return "\n".join(lines) + "\n"


def render_json(root: Path, inventory: Inventory, findings: list[Finding], notes: list[str]) -> str:
    payload = {
        "target": str(root),
        "disclaimer": "Static evidence only; this report does not prove conformance, runtime behavior, browser compatibility, or field performance.",
        "inventory": asdict(inventory),
        "summary": {
            "total": len(findings),
            "by_severity": dict(Counter(f.severity for f in findings)),
            "by_area": dict(Counter(f.area for f in findings)),
        },
        "findings": [asdict(f) for f in findings],
        "notes": notes,
    }
    return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"


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

    files = list(iter_files(root, args.include_generated))
    contents: dict[Path, str] = {}
    for path in files:
        text = read_text(path)
        if text is not None:
            contents[path] = text

    inventory, notes = detect_inventory(root, files, contents)
    findings: list[Finding] = []
    flags = defaultdict(bool)

    base = root if root.is_dir() else root.parent
    for path, text in contents.items():
        try:
            rel = str(path.relative_to(base))
        except ValueError:
            rel = str(path)
        suffix = path.suffix.lower()
        if suffix in MARKUP_EXTENSIONS:
            scan_markup(rel, text, findings)
            scan_html_script_loading(rel, text, findings)
        if suffix in STYLE_EXTENSIONS:
            scan_styles(rel, text, findings, flags)
        if suffix in SCRIPT_EXTENSIONS:
            scan_scripts(rel, text, findings, flags)

    project_level_findings(root, inventory, flags, findings)
    findings = deduplicate(findings)

    output = render_markdown(root, inventory, findings, notes) if args.format == "markdown" else render_json(root, inventory, findings, notes)
    if args.output:
        destination = Path(args.output).expanduser()
        destination.parent.mkdir(parents=True, exist_ok=True)
        destination.write_text(output, encoding="utf-8")
    else:
        sys.stdout.write(output)

    if args.fail_on:
        threshold = SEVERITY_RANK[args.fail_on]
        if any(SEVERITY_RANK[f.severity] >= threshold for f in findings):
            return 2
    return 0


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