#!/usr/bin/env python3
"""Static evidence collector for dark mode and theme architecture.

The scanner intentionally reports evidence and review prompts. It does not prove
WCAG conformance, runtime correctness, or browser support.
"""

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",
}
SYSTEM_COLORS = {
    "accentcolor", "accentcolortext", "activetext", "buttonborder", "buttonface", "buttontext",
    "canvas", "canvastext", "field", "fieldtext", "graytext", "highlight", "highlighttext",
    "linktext", "mark", "marktext", "selecteditem", "selecteditemtext", "visitedtext",
}
COLOR_RE = re.compile(
    r"(?<![\w-])(?:#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\([^;{}]+\))"
)
CUSTOM_PROP_DEF_RE = re.compile(r"(--[a-zA-Z0-9_-]+)\s*:\s*([^;{}]+)")
CUSTOM_PROP_USE_RE = re.compile(r"var\(\s*(--[a-zA-Z0-9_-]+)(?:\s*,[^)]*)?\)")
HEX_OR_FUNCTION_RE = re.compile(r"#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(", re.I)


@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
    theme_selectors: list[str] | None = None
    media_features: list[str] | None = None
    storage_mechanisms: list[str] | None = None
    frameworks: list[str] | None = None
    theme_packages: list[str] | None = None
    custom_properties_defined: int = 0
    custom_properties_used: int = 0
    repeated_colors: list[dict] | None = None

    def __post_init__(self) -> None:
        self.theme_selectors = self.theme_selectors or []
        self.media_features = self.media_features or []
        self.storage_mechanisms = self.storage_mechanisms or []
        self.frameworks = self.frameworks or []
        self.theme_packages = self.theme_packages or []
        self.repeated_colors = self.repeated_colors 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.color_occurrences: dict[str, list[tuple[Path, int, str]]] = defaultdict(list)
        self.custom_defs: dict[str, list[tuple[Path, int, str]]] = defaultdict(list)
        self.custom_uses: dict[str, list[tuple[Path, int, str]]] = defaultdict(list)
        self.dark_custom_defs: set[str] = set()
        self.theme_signals: set[str] = set()
        self.media_signals: set[str] = set()
        self.storage_signals: set[str] = set()
        self.package_versions: dict[str, str] = {}

    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(line, 1),
                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 current, dirs, files in os.walk(self.root):
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".tmp")]
            for name in files:
                path = Path(current) / name
                if self.output_path and path.resolve() == self.output_path:
                    continue
                if path.suffix.lower() in TEXT_EXTENSIONS or name in {"package.json", "bower.json"}:
                    yield path

    def load(self) -> None:
        for path in self.iter_files():
            try:
                size = path.stat().st_size
                if size > self.max_bytes:
                    continue
                text = path.read_text(encoding="utf-8", errors="replace")
            except OSError:
                continue
            if self.is_generated_report(text):
                continue
            self.contents[path] = text
            self.lines[path] = text.splitlines()
            self.inventory.files_scanned += 1
            self.inventory.bytes_scanned += size
            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
            self.collect_signals(path, text)
            if path.name == "package.json":
                self.parse_package_json(path, text)

    @staticmethod
    def is_generated_report(text: str) -> bool:
        head = text[:2000].lstrip()
        if head.startswith("# Dark Mode and Theming Static Review"):
            return True
        if head.startswith("{"):
            try:
                data = json.loads(text)
            except json.JSONDecodeError:
                return False
            return isinstance(data, dict) and "inventory" in data and "findings" in data
        return False

    def collect_signals(self, path: Path, text: str) -> None:
        low = text.lower()
        selector_patterns = {
            "data-theme": r"data-theme|\[data-theme",
            "data-bs-theme": r"data-bs-theme|\[data-bs-theme",
            "class-dark": r"(?:^|[\s.'\"\[])dark(?:-mode)?(?:[\s.'\"\]#:{]|$)",
            "theme-class": r"\.theme-[a-z0-9_-]+|theme-[a-z0-9_-]+",
        }
        for name, pattern in selector_patterns.items():
            if re.search(pattern, low, re.M):
                self.theme_signals.add(name)
        media_patterns = {
            "prefers-color-scheme": "prefers-color-scheme",
            "forced-colors": "forced-colors",
            "prefers-contrast": "prefers-contrast",
            "inverted-colors": "inverted-colors",
        }
        for name, token in media_patterns.items():
            if token in low:
                self.media_signals.add(name)
        for name, token in {
            "localStorage": "localstorage",
            "sessionStorage": "sessionstorage",
            "cookie": "document.cookie",
            "account/server": "theme" if path.suffix.lower() in {".php", ".cfm", ".cfc", ".jsp", ".aspx", ".cshtml", ".razor"} else "__never__",
        }.items():
            if token in low:
                self.storage_signals.add(name)

        for idx, line in enumerate(text.splitlines(), start=1):
            for color in COLOR_RE.findall(line):
                normalized = re.sub(r"\s+", "", color.lower())
                self.color_occurrences[normalized].append((path, idx, line.strip()))
            for match in CUSTOM_PROP_DEF_RE.finditer(line):
                prop = match.group(1)
                self.custom_defs[prop].append((path, idx, line.strip()))
                if self.line_in_dark_context(text, idx):
                    self.dark_custom_defs.add(prop)
            for match in CUSTOM_PROP_USE_RE.finditer(line):
                self.custom_uses[match.group(1)].append((path, idx, line.strip()))

    @staticmethod
    def line_in_dark_context(text: str, line_number: int) -> bool:
        lines = text.splitlines()
        start = max(0, line_number - 12)
        context = "\n".join(lines[start:line_number]).lower()
        return bool(re.search(r"dark|prefers-color-scheme\s*:\s*dark|data-(?:bs-)?theme\s*=.*dark", context))

    def parse_package_json(self, path: Path, text: str) -> None:
        try:
            data = json.loads(text)
        except json.JSONDecodeError:
            self.add(
                "THEME-PKG-001", "medium", "high", "tooling", path, 1,
                "Invalid package.json prevents reliable theme inventory",
                "package.json could not be parsed as JSON.",
                "Theme libraries and framework versions cannot be verified reliably.",
                "Repair the manifest before changing theme dependencies.",
            )
            return
        deps: dict[str, str] = {}
        for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
            values = data.get(section, {})
            if isinstance(values, dict):
                deps.update({str(k): str(v) for k, v in values.items()})
        self.package_versions.update(deps)
        framework_map = {
            "react": "React", "vue": "Vue", "@angular/core": "Angular", "svelte": "Svelte",
            "next": "Next.js", "nuxt": "Nuxt", "@sveltejs/kit": "SvelteKit", "jquery": "jQuery",
            "bootstrap": "Bootstrap",
        }
        themes = {
            "styled-components", "@emotion/react", "@mui/material", "@chakra-ui/react", "next-themes",
            "theme-ui", "@fluentui/react", "antd", "vuetify", "@angular/material", "tailwindcss",
        }
        for dep, label in framework_map.items():
            if dep in deps:
                self.inventory.frameworks.append(f"{label} {deps[dep]}")
        for dep in sorted(themes):
            if dep in deps:
                self.inventory.theme_packages.append(f"{dep} {deps[dep]}")

    def scan_lines(self) -> None:
        for path, lines in self.lines.items():
            suffix = path.suffix.lower()
            for idx, line in enumerate(lines, start=1):
                stripped = line.strip()
                low = stripped.lower()

                if re.search(r"filter\s*:\s*(?:invert|hue-rotate)\s*\(", low):
                    self.add(
                        "THEME-COLOR-001", "critical", "high", "theme implementation", path, idx,
                        "Page or component theming uses color inversion",
                        stripped,
                        "Inversion corrupts photos, logos, status colors, focus indicators, shadows, and embedded content.",
                        "Replace inversion with semantic theme tokens and explicit asset handling.",
                    )
                if re.search(r"forced-color-adjust\s*:\s*none", low):
                    self.add(
                        "THEME-A11Y-001", "high", "high", "forced colors", path, idx,
                        "Forced-color adaptation is disabled",
                        stripped,
                        "The element can ignore the user-selected forced palette and become unreadable or invisible.",
                        "Remove the opt-out unless this isolated element supplies an equivalent system-color treatment.",
                        "Test the element in a real forced-colors environment after any exception.",
                    )
                if re.search(r"-ms-high-contrast-adjust\s*:\s*none", low):
                    self.add(
                        "THEME-A11Y-002", "high", "high", "forced colors", path, idx,
                        "Legacy high-contrast adaptation is disabled",
                        stripped,
                        "The copied legacy override can suppress user color adjustments in older Microsoft engines.",
                        "Remove the global opt-out and replace it with targeted forced-colors handling using system colors.",
                    )
                if re.search(r"color-scheme\s*:\s*only\s+light", low):
                    self.add(
                        "THEME-UA-001", "medium", "high", "browser color scheme", path, idx,
                        "The element forbids browser dark color-scheme adaptation",
                        stripped,
                        "Native controls and browser UI may remain light even when the application presents a dark surface.",
                        "Use only-light only for a verified isolated surface; otherwise declare the schemes actually supported.",
                    )
                if "prefers-color-scheme: no-preference" in low or "prefers-color-scheme : no-preference" in low:
                    self.add(
                        "THEME-UA-002", "high", "high", "browser color scheme", path, idx,
                        "Obsolete or invalid prefers-color-scheme value",
                        stripped,
                        "The rule may never match in current implementations.",
                        "Use light and dark queries and define an explicit default outside the query.",
                    )
                if re.search(r"(?:html|body|:root)[^{]*\*\s*\{", low) and "dark" in low:
                    self.add(
                        "THEME-CSS-001", "high", "medium", "theme architecture", path, idx,
                        "Dark-theme wildcard override has broad scope",
                        stripped,
                        "A wildcard can override nested components, third-party widgets, SVG, and local theme islands unpredictably.",
                        "Move values to semantic custom properties and let components consume the roles they need.",
                    )
                if re.search(r"transition\s*:\s*(?:all|(?:color|background|background-color|border-color)(?:\s|,))", low):
                    self.add(
                        "THEME-MOTION-001", "medium", "medium", "theme transition", path, idx,
                        "Broad color transition may animate the entire theme switch",
                        stripped,
                        "Large synchronous color transitions can cause visual discomfort, sluggish painting, and distracting first-paint effects.",
                        "Limit transitions to deliberate components, respect reduced motion, and disable them during initial theme resolution.",
                    )
                if re.search(r"outline\s*:\s*(?:0|none)\b", low):
                    context = "\n".join(lines[max(0, idx - 3): min(len(lines), idx + 3)]).lower()
                    if ":focus" in context and "focus-visible" not in context:
                        self.add(
                            "THEME-A11Y-003", "high", "medium", "focus", path, idx,
                            "Focus outline is removed without an evident replacement",
                            stripped,
                            "Keyboard users may lose the focus indicator in one or more themes.",
                            "Provide a tokenized focus-visible indicator that contrasts on every supported surface.",
                        )
                if re.search(r"(?:background|background-color)\s*:\s*(?:transparent|none)\b", low) and "focus" in "\n".join(lines[max(0, idx-2):idx+1]).lower():
                    self.add(
                        "THEME-A11Y-004", "medium", "low", "focus", path, idx,
                        "Focus styling may depend on a transparent background",
                        stripped,
                        "The focus state may disappear on nested or alternate theme surfaces.",
                        "Verify adjacent contrast on every actual background and add a stable outline or border when needed.",
                    )
                if suffix in STYLE_EXTENSIONS and re.search(r"(?:fill|stroke)\s*:\s*(?:#[0-9a-f]{3,8}|rgb|hsl)", low):
                    self.add(
                        "THEME-ASSET-001", "medium", "medium", "icons and SVG", path, idx,
                        "SVG color is hard-coded",
                        stripped,
                        "The icon may disappear or conflict with semantic state colors in alternate themes and forced colors.",
                        "Use currentColor or semantic SVG tokens when the artwork is intended to adapt.",
                        "Do not recolor brand, content, map, or data assets without semantic review.",
                    )
                if suffix == ".svg" and re.search(r"(?:fill|stroke)=['\"](?:#[0-9a-f]{3,8}|rgb|hsl)", low):
                    self.add(
                        "THEME-ASSET-002", "medium", "medium", "icons and SVG", path, idx,
                        "SVG file contains fixed colors",
                        stripped,
                        "A transparent asset can be illegible on alternate surfaces.",
                        "Classify the asset and provide currentColor, tokens, or explicit light/dark variants as appropriate.",
                    )
                if re.search(r"(?:background|background-image)\s*:\s*url\(", low):
                    self.add(
                        "THEME-ASSET-003", "medium", "low", "images", path, idx,
                        "Background image requires theme review",
                        stripped,
                        "Text, gradients, logos, or baked-in colors may lose contrast in alternate themes.",
                        "Verify the image on every supported surface and provide a deliberate alternate asset when needed.",
                    )
                if re.search(r"(?:document\.documentelement|document\.body)\.style\.(?:color|background|backgroundcolor|bordercolor)", low):
                    self.add(
                        "THEME-JS-001", "high", "high", "runtime theming", path, idx,
                        "Theme code rewrites document colors imperatively",
                        stripped,
                        "Node-level style mutation bypasses semantic tokens, state coverage, SSR, and forced-colors behavior.",
                        "Apply a validated root mode and express presentation through CSS custom properties and component contracts.",
                    )
                if re.search(r"queryselectorall\([^)]*\).*style\.(?:color|background|backgroundcolor)", low):
                    self.add(
                        "THEME-JS-002", "high", "medium", "runtime theming", path, idx,
                        "Theme code appears to mutate many nodes directly",
                        stripped,
                        "Imperative recoloring is fragile for dynamic content, portals, framework rendering, and accessibility modes.",
                        "Replace node iteration with inherited semantic tokens and one root theme state.",
                    )
                if "domcontentloaded" in low or re.search(r"addEventListener\(['\"]load['\"]", stripped):
                    context = "\n".join(lines[max(0, idx - 5): min(len(lines), idx + 8)]).lower()
                    if re.search(r"theme|dark|color-scheme|dataset\.(?:theme|bsTheme)|classlist.*dark", context):
                        self.add(
                            "THEME-PAINT-001", "high", "medium", "first paint", path, idx,
                            "Initial theme waits for DOMContentLoaded or window load",
                            stripped,
                            "The page can paint the wrong theme before the handler runs.",
                            "Resolve the effective root theme before first paint or render it on the server.",
                        )
                if re.search(r"useeffect\s*\(", low):
                    context = "\n".join(lines[idx - 1: min(len(lines), idx + 15)]).lower()
                    if re.search(r"theme|dark|document\.documentelement|data-theme|classlist", context):
                        self.add(
                            "THEME-PAINT-002", "high", "medium", "hydration", path, idx,
                            "React effect may be the first place the theme is applied",
                            stripped,
                            "Effects run after render and can cause a theme flash or hydration mismatch.",
                            "Align SSR and initial client state, or apply a CSP-compatible pre-render bootstrap.",
                        )
                if re.search(r"(?:localstorage|sessionstorage)\.getitem\([^)]*theme", low):
                    context = "\n".join(lines[max(0, idx - 8): min(len(lines), idx + 16)]).lower()
                    if "try" not in context or "catch" not in context:
                        self.add(
                            "THEME-STATE-001", "medium", "medium", "persistence", path, idx,
                            "Theme storage access has no nearby failure handling",
                            stripped,
                            "Storage can be unavailable or throw in restricted contexts, breaking theme initialization.",
                            "Wrap access, validate values, and fall back to system or product default.",
                        )
                    if not re.search(r"allowed|includes\(|set\(|===\s*['\"]light|===\s*['\"]dark|switch\s*\(", context):
                        self.add(
                            "THEME-STATE-002", "high", "low", "persistence", path, idx,
                            "Stored theme value may be applied without an allowlist",
                            stripped,
                            "Unexpected values can create invalid selectors, broken UI, or unsafe style construction.",
                            "Validate stored values against the supported theme identifiers before applying them.",
                        )
                if "matchmedia" in low and "prefers-color-scheme" in low:
                    context = "\n".join(lines[max(0, idx - 5): min(len(lines), idx + 25)]).lower()
                    if "system" in context and not re.search(r"addeventlistener\s*\(\s*['\"]change|addlistener\s*\(", context):
                        self.add(
                            "THEME-STATE-003", "medium", "medium", "system preference", path, idx,
                            "System mode may not react to preference changes",
                            stripped,
                            "A page left open can retain a stale resolved theme after the operating-system setting changes.",
                            "Listen for media-query changes only while the stored preference is system and clean up scoped listeners.",
                        )
                if re.search(r"prefers-contrast[^\n]*(?:dark|light)|(?:dark|light)[^\n]*prefers-contrast", low):
                    self.add(
                        "THEME-A11Y-005", "high", "medium", "contrast preference", path, idx,
                        "Contrast preference appears to select a light or dark theme",
                        stripped,
                        "Contrast preference and color scheme are independent and can represent different user needs.",
                        "Handle prefers-contrast separately from prefers-color-scheme and forced-colors.",
                    )
                if "data-bs-theme" in low:
                    version = self.package_versions.get("bootstrap")
                    if version and not self.version_at_least(version, 5, 3):
                        self.add(
                            "THEME-BS-001", "high", "high", "Bootstrap", path, idx,
                            "data-bs-theme is used with Bootstrap older than 5.3",
                            stripped,
                            f"Detected Bootstrap version range: {version}",
                            "Use a scoped legacy theme adapter or upgrade Bootstrap before relying on color-mode variables.",
                        )
                if re.search(r"\.(?:navbar-dark|dropdown-menu-dark|carousel-dark|btn-close-white)\b|(?:navbar-dark|dropdown-menu-dark|carousel-dark|btn-close-white)", low):
                    self.add(
                        "THEME-BS-002", "medium", "high", "Bootstrap", path, idx,
                        "Deprecated Bootstrap dark variant is present",
                        stripped,
                        "The variant can conflict with Bootstrap 5.3 color modes and local theme scoping.",
                        "Migrate to data-bs-theme and semantic color-mode variables while preserving component behavior.",
                    )
                if re.search(r"\b(?:bg-light|bg-dark|text-light|text-dark)\b", low):
                    self.add(
                        "THEME-BS-003", "medium", "medium", "Bootstrap", path, idx,
                        "Absolute Bootstrap light/dark utility is used",
                        stripped,
                        "The utility encodes a palette direction rather than a semantic role and may not adapt across color modes.",
                        "Use semantic emphasis, subtle, body, or product token roles after verifying the exact Bootstrap version.",
                    )
                if re.search(r"theme\s*:\s*['\"](?:vs|vs-dark|light|dark)['\"]", low) and re.search(r"monaco|codemirror|editor", "\n".join(lines[max(0, idx-8):idx+8]).lower()):
                    self.add(
                        "THEME-EDITOR-001", "medium", "medium", "code editor", path, idx,
                        "Code editor theme is hard-coded",
                        stripped,
                        "The editor can diverge from user preference and fail to update when the application theme changes.",
                        "Map editor themes to the resolved application theme and update live instances safely.",
                    )
                if re.search(r"new\s+(?:chart|echarts|highcharts)|chart\.register|apexcharts|plotly", low):
                    context = "\n".join(lines[max(0, idx - 10): min(len(lines), idx + 25)]).lower()
                    if not re.search(r"theme|color-scheme|dark|tokens?|cssvar|mutationobserver", context):
                        self.add(
                            "THEME-CHART-001", "medium", "low", "charts", path, idx,
                            "Chart initialization has no evident theme adapter",
                            stripped,
                            "Canvas or library-owned pixels normally do not update when CSS custom properties change.",
                            "Provide a chart theme adapter and update existing instances on effective-theme changes.",
                        )
                if re.search(r"content-security-policy", low) and "unsafe-inline" in low:
                    self.add(
                        "THEME-SEC-001", "medium", "high", "CSP", path, idx,
                        "CSP allows unsafe inline script or style",
                        stripped,
                        "A theme bootstrap may work, but the policy weakens protection for the whole document.",
                        "Use a nonce, hash, external early script, or server-rendered theme instead of broad unsafe-inline.",
                    )
                if re.search(r"style=\s*['\"][^'\"]*(?:color|background|border-color)\s*:", low):
                    self.add(
                        "THEME-CSS-002", "medium", "medium", "inline style", path, idx,
                        "Inline color style bypasses the theme token contract",
                        stripped,
                        "The value can remain fixed in alternate themes and is difficult to override without specificity escalation.",
                        "Replace it with a semantic class, custom property, or component API.",
                    )
                if re.search(r"var\(\s*--(?:blue|red|green|gray|grey|slate|neutral|white|black|yellow|orange|purple|pink)-", low):
                    self.add(
                        "THEME-TOKEN-001", "medium", "medium", "tokens", path, idx,
                        "Component consumes a palette-named custom property",
                        stripped,
                        "The component is coupled to a raw color rather than a semantic role and becomes harder to theme safely.",
                        "Alias palette primitives through semantic and component tokens.",
                    )

    @staticmethod
    def version_at_least(raw: str, major: int, minor: int) -> bool:
        match = re.search(r"(\d+)\.(\d+)", raw)
        if not match:
            return False
        return (int(match.group(1)), int(match.group(2))) >= (major, minor)

    def scan_forced_color_blocks(self) -> None:
        block_re = re.compile(r"@media\s*\([^)]*forced-colors\s*:\s*active[^)]*\)\s*\{", re.I)
        for path, text in self.contents.items():
            for match in block_re.finditer(text):
                start_line = text.count("\n", 0, match.start()) + 1
                block = self.extract_block(text, match.end() - 1)
                if HEX_OR_FUNCTION_RE.search(block):
                    self.add(
                        "THEME-A11Y-006", "high", "medium", "forced colors", path, start_line,
                        "Forced-colors override uses author color literals",
                        block[:240].replace("\n", " "),
                        "Hard-coded values can fight the user-selected palette and produce unexpected contrast.",
                        "Use matched CSS system colors and keep forced-color-adjust at auto unless a targeted exception is justified.",
                    )
                lower = block.lower()
                if not any(color in lower for color in SYSTEM_COLORS):
                    self.add(
                        "THEME-A11Y-007", "medium", "medium", "forced colors", path, start_line,
                        "Forced-colors block does not visibly use system colors",
                        block[:240].replace("\n", " "),
                        "The override may not integrate with the user's palette.",
                        "Review the block and use appropriate matched system-color keywords for explicit colors.",
                    )

    @staticmethod
    def extract_block(text: str, opening_brace_index: int) -> str:
        depth = 0
        for idx in range(opening_brace_index, len(text)):
            char = text[idx]
            if char == "{":
                depth += 1
            elif char == "}":
                depth -= 1
                if depth == 0:
                    return text[opening_brace_index: idx + 1]
        return text[opening_brace_index:]

    def aggregate_checks(self) -> None:
        all_text = "\n".join(self.contents.values())
        low = all_text.lower()
        supports_dark = bool(
            re.search(r"prefers-color-scheme\s*:\s*dark|data-(?:bs-)?theme[^\n]{0,80}dark|\.dark(?:-mode)?\b|theme-dark", low)
        )
        supports_light = bool(
            re.search(r"prefers-color-scheme\s*:\s*light|data-(?:bs-)?theme[^\n]{0,80}light|\.light(?:-mode)?\b|theme-light", low)
        )
        has_color_scheme = bool(re.search(r"(?:^|[;{\s])color-scheme\s*:", low, re.M))
        has_meta_color_scheme = bool(re.search(r"<meta[^>]+name\s*=\s*['\"]color-scheme['\"]", low))
        has_forced_colors = "forced-colors" in low
        has_theme_meta = bool(re.search(r"<meta[^>]+name\s*=\s*['\"]theme-color['\"]", low))
        has_media_theme_meta = bool(re.search(r"<meta[^>]+name\s*=\s*['\"]theme-color['\"][^>]+media\s*=", low))

        anchor = next(iter(self.contents.keys()), self.root)
        if supports_dark and not has_color_scheme:
            self.add(
                "THEME-UA-003", "medium", "high", "browser color scheme", anchor, 1,
                "Dark application styling is present without a color-scheme declaration",
                "Dark-theme selectors or media queries were detected, but no CSS color-scheme property was found.",
                "Native controls, scrollbars, form widgets, and browser-provided UI can remain mismatched.",
                "Declare the schemes the document or subtree can render, then verify native controls in supported browsers.",
            )
        if supports_dark and supports_light and not has_meta_color_scheme:
            self.add(
                "THEME-PAINT-003", "low", "medium", "first paint", anchor, 1,
                "No early meta color-scheme declaration was detected",
                "Both light and dark theme signals were detected without meta[name=color-scheme].",
                "The browser may initially paint canvas or controls with a mismatched scheme before CSS is available.",
                "Consider an early meta color-scheme declaration that matches the supported schemes and precedence.",
            )
        if supports_dark and not has_forced_colors:
            self.add(
                "THEME-A11Y-008", "medium", "low", "forced colors", anchor, 1,
                "No forced-colors strategy was detected",
                "Dark-theme support exists, but no forced-colors media query was found.",
                "Background-based distinctions, SVG, charts, or custom controls may disappear in a user-forced palette.",
                "Test the product in forced colors and add only the targeted overrides that testing demonstrates are necessary.",
            )
        if supports_dark and not has_theme_meta:
            self.add(
                "THEME-UA-004", "low", "low", "browser chrome", anchor, 1,
                "No theme-color metadata was detected",
                "The application supports dark styling but no meta[name=theme-color] was found.",
                "Browser chrome or installed-app surfaces may not match the active theme.",
                "Decide whether theme-color metadata is part of the supported platform contract and test current browsers.",
            )
        elif supports_dark and has_theme_meta and not has_media_theme_meta:
            self.add(
                "THEME-UA-005", "low", "low", "browser chrome", anchor, 1,
                "Theme-color metadata may not distinguish light and dark schemes",
                "theme-color metadata exists without an evident media condition.",
                "Browser chrome can remain fixed while the application theme changes.",
                "Use deliberate light/dark metadata or update it through the resolved theme when supported and required.",
            )

        mechanisms = sorted(self.theme_signals | ({"prefers-color-scheme"} if "prefers-color-scheme" in self.media_signals else set()))
        if len(mechanisms) >= 3:
            self.add(
                "THEME-ARCH-001", "high", "medium", "theme architecture", anchor, 1,
                "Multiple theme mechanisms coexist",
                ", ".join(mechanisms),
                "Independent class, attribute, Bootstrap, and media-query systems can disagree about precedence and scope.",
                "Define one effective root contract and migrate other mechanisms behind explicit adapters.",
            )
        elif len(mechanisms) == 2:
            self.add(
                "THEME-ARCH-002", "medium", "low", "theme architecture", anchor, 1,
                "More than one theme mechanism requires a precedence review",
                ", ".join(mechanisms),
                "The mechanisms may be intentional, but unresolved precedence can cause local or first-paint mismatches.",
                "Document which mechanism owns preference, resolution, root state, and local theme islands.",
            )

        if "color-scheme: only light" in low and supports_dark:
            self.add(
                "THEME-UA-006", "high", "high", "browser color scheme", anchor, 1,
                "Global only-light declaration conflicts with dark-theme support",
                "Both dark-theme signals and color-scheme: only light were detected.",
                "Authored surfaces and browser-provided controls can use incompatible schemes.",
                "Remove the global contradiction or scope only-light to a verified isolated surface.",
            )

        if "light-dark(" in low and not has_color_scheme:
            self.add(
                "THEME-COLOR-002", "high", "high", "CSS color", anchor, 1,
                "light-dark() is used without enabling light and dark color schemes",
                "light-dark() was detected, but no color-scheme declaration was found.",
                "The function cannot select values according to the intended used color scheme.",
                "Declare an appropriate color-scheme on the root or relevant subtree and verify browser support.",
            )

        undefined = sorted(set(self.custom_uses) - set(self.custom_defs))
        for prop in undefined[:30]:
            path, line, evidence = self.custom_uses[prop][0]
            self.add(
                "THEME-TOKEN-002", "high", "high", "tokens", path, line,
                f"Custom property {prop} has no definition in scanned source",
                evidence,
                "The value can become invalid and make text, backgrounds, borders, or icons disappear in one theme.",
                "Define the property in the theme contract or provide a deliberate fallback in var().",
                "Confirm whether the property is injected by a dependency, host shell, or runtime configuration.",
            )

        repeated = []
        for color, occurrences in self.color_occurrences.items():
            if len(occurrences) >= 4:
                repeated.append((color, occurrences))
        repeated.sort(key=lambda item: len(item[1]), reverse=True)
        for color, occurrences in repeated[:15]:
            self.inventory.repeated_colors.append({"value": color, "count": len(occurrences)})
            path, line, evidence = occurrences[0]
            self.add(
                "THEME-TOKEN-003", "medium", "medium", "tokens", path, line,
                f"Color literal is repeated {len(occurrences)} times",
                f"{color}: {evidence}",
                "Repeated literals drift across themes and make semantic ownership difficult to audit.",
                "Determine whether the value represents a reusable semantic role; if so, replace it with a governed token.",
            )

        root_props = set(self.custom_defs)
        if supports_dark and len(root_props) >= 8:
            missing_dark = sorted(root_props - self.dark_custom_defs)
            likely_color = [p for p in missing_dark if re.search(r"color|bg|background|border|surface|text|shadow|focus|accent|fill|stroke", p, re.I)]
            for prop in likely_color[:20]:
                path, line, evidence = self.custom_defs[prop][0]
                self.add(
                    "THEME-TOKEN-004", "medium", "low", "tokens", path, line,
                    f"Theme-related token {prop} has no detected dark override",
                    evidence,
                    "The token may remain tuned for the default theme and fail on a dark surface.",
                    "Confirm whether the token is intentionally invariant, derived elsewhere, or missing from the dark semantic map.",
                )

        if supports_dark and re.search(r"(?:chart\.js|echarts|highcharts|apexcharts|plotly|recharts|d3)", low) and not re.search(r"chart[^\n]{0,100}(?:theme|dark)|(?:theme|dark)[^\n]{0,100}chart", low):
            self.add(
                "THEME-CHART-002", "medium", "low", "charts", anchor, 1,
                "Chart library detected without an evident application theme bridge",
                "A charting dependency or API was detected.",
                "Charts can retain fixed axes, labels, tooltips, and series after the application theme changes.",
                "Define a chart token adapter, non-color distinctions, live update behavior, and export treatment.",
            )

        self.inventory.theme_selectors = sorted(self.theme_signals)
        self.inventory.media_features = sorted(self.media_signals)
        self.inventory.storage_mechanisms = sorted(self.storage_signals)
        self.inventory.frameworks = sorted(set(self.inventory.frameworks))
        self.inventory.theme_packages = sorted(set(self.inventory.theme_packages))
        self.inventory.custom_properties_defined = len(self.custom_defs)
        self.inventory.custom_properties_used = len(self.custom_uses)

    def scan(self) -> tuple[Inventory, list[Finding]]:
        self.load()
        self.scan_lines()
        self.scan_forced_color_blocks()
        self.aggregate_checks()
        unique: dict[tuple, Finding] = {}
        for finding in self.findings:
            key = (finding.rule_id, finding.file, finding.line, finding.evidence)
            unique[key] = finding
        findings = sorted(
            unique.values(),
            key=lambda f: (-SEVERITY_RANK[f.severity], f.file, f.line, f.rule_id),
        )
        return self.inventory, findings


def render_markdown(inventory: Inventory, findings: Sequence[Finding]) -> str:
    counts = Counter(f.severity for f in findings)
    lines = [
        "# Dark Mode and Theming Static Review",
        "",
        "> Static evidence only. Verify first paint, computed colors, contrast, interaction, forced colors, SSR, hydration, and assistive technology behavior manually.",
        "",
        "## 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"- Theme selectors: {', '.join(inventory.theme_selectors or []) or 'none detected'}",
        f"- Media features: {', '.join(inventory.media_features or []) or 'none detected'}",
        f"- Persistence signals: {', '.join(inventory.storage_mechanisms or []) or 'none detected'}",
        f"- Frameworks: {', '.join(inventory.frameworks or []) or 'none detected'}",
        f"- Theme packages: {', '.join(inventory.theme_packages or []) or 'none detected'}",
        f"- Custom properties defined: {inventory.custom_properties_defined}",
        f"- Custom properties used: {inventory.custom_properties_used}",
    ]
    if inventory.repeated_colors:
        values = ", ".join(f"`{item['value']}` x{item['count']}" for item in inventory.repeated_colors[:10])
        lines.append(f"- Repeated color literals: {values}")
    lines.extend(["", "## Findings", ""])
    if not findings:
        lines.append("No static findings were detected. This does not establish accessibility, runtime, or visual correctness.")
        return "\n".join(lines) + "\n"
    for index, finding in enumerate(findings, start=1):
        location = f"{finding.file}:{finding.line}"
        lines.extend([
            f"### {index}. [{finding.severity.upper()}] {finding.title}",
            "",
            f"- Rule: `{finding.rule_id}`",
            f"- Confidence: {finding.confidence}",
            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("")
    return "\n".join(lines)


def parse_args(argv: Sequence[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect dark mode and theme architecture using static evidence.")
    parser.add_argument("path", type=Path, help="Project directory or source file")
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--output", type=Path, help="Write the report to a file")
    parser.add_argument("--fail-on", choices=("critical", "high", "medium", "low", "info"), help="Exit 2 when this severity or higher is found")
    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:])
    root = args.path
    if not root.exists():
        print(f"error: path does not exist: {root}", file=sys.stderr)
        return 1
    scanner = Scanner(root, args.output, args.max_file_bytes)
    inventory, findings = scanner.scan()
    if args.format == "json":
        report = json.dumps(
            {"inventory": asdict(inventory), "findings": [asdict(f) for f in findings]},
            indent=2,
            ensure_ascii=False,
        ) + "\n"
    else:
        report = render_markdown(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)
    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())
