#!/usr/bin/env python3
"""Static CSS architecture inventory and risk scanner.

Uses only the Python standard library. The scanner reports source-level evidence
that can guide a CSS architecture review. It cannot resolve the complete runtime
cascade, determine computed styles, prove unused CSS, or establish accessibility
or visual correctness.
"""

from __future__ import annotations

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

SKIP_DIRS = {
    ".git", ".hg", ".svn", ".idea", ".vscode", "node_modules", "vendor",
    "dist", "build", "coverage", ".next", ".nuxt", ".output", "target",
    "bin", "obj", ".cache", ".parcel-cache", ".turbo",
}

TEXT_EXTENSIONS = {
    ".css", ".scss", ".sass", ".less", ".pcss", ".postcss",
    ".html", ".htm", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx",
    ".vue", ".svelte", ".php", ".cfm", ".cfml", ".jsp", ".aspx",
    ".cshtml", ".razor", ".json", ".yaml", ".yml", ".md", ".mdx",
}
STYLE_EXTENSIONS = {".css", ".scss", ".sass", ".less", ".pcss", ".postcss", ".vue", ".svelte"}
MARKUP_EXTENSIONS = {".html", ".htm", ".php", ".cfm", ".cfml", ".jsp", ".aspx", ".cshtml", ".razor", ".vue", ".svelte", ".jsx", ".tsx"}
SCRIPT_EXTENSIONS = {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".vue", ".svelte"}
MAX_FILE_BYTES = 2_000_000
SEVERITY_RANK = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}

IMPORTANT_RE = re.compile(r"!important\b", re.I)
ID_SELECTOR_RE = re.compile(r"#[A-Za-z_][A-Za-z0-9_-]*")
CLASS_SELECTOR_RE = re.compile(r"\.[A-Za-z_-][A-Za-z0-9_-]*")
ATTR_SELECTOR_RE = re.compile(r"\[[^\]]+\]")
PSEUDO_CLASS_RE = re.compile(r"(?<!:):(?!:)[A-Za-z-]+(?:\([^)]*\))?")
CUSTOM_PROP_DEF_RE = re.compile(r"(?m)(--[A-Za-z0-9_-]+)\s*:")
CUSTOM_PROP_USE_RE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)(?:\s*,[^)]*)?\)")
CUSTOM_PROP_NO_FALLBACK_RE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)\s*\)")
HEX_RE = re.compile(r"(?<![A-Za-z0-9_-])#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})(?![0-9a-fA-F])")
COLOR_FN_RE = re.compile(r"\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\([^;{}]+\)", re.I)
DIMENSION_RE = re.compile(r"(?<![A-Za-z0-9_.-])(-?(?:\d+\.\d+|\d+|\.\d+))(px|rem|em|ch|vh|vw|vmin|vmax)\b", re.I)
Z_INDEX_RE = re.compile(r"\bz-index\s*:\s*(-?\d+)\b", re.I)
MEDIA_RE = re.compile(r"@media\s*([^\{]+)\{", re.I)
KEYFRAMES_RE = re.compile(r"@(?:-webkit-)?keyframes\s+([A-Za-z_][A-Za-z0-9_-]*)", re.I)
SASS_IMPORT_RE = re.compile(r"(?m)^\s*@import\s+[^;\n]+;?")
CSS_IMPORT_RE = re.compile(r"(?m)^\s*@import\s+(?:url\()?['\"]?[^;\n]+")
SASS_EXTEND_RE = re.compile(r"@extend\s+(?!%)([^;\n]+)", re.I)
INLINE_STYLE_RE = re.compile(r"\sstyle\s*=\s*['\"]", re.I)
JSX_STYLE_RE = re.compile(r"\bstyle\s*=\s*\{\{|style\s*=\s*\{[^}\n]+\}")
CSS_IN_JS_RE = re.compile(r"\b(?:styled\.[A-Za-z]+|styled\(|css`|createGlobalStyle|makeStyles\(|createStyles\(|sx=\{)")
LEGACY_HACK_RE = re.compile(
    r"expression\s*\(|behavior\s*:|progid:DXImageTransform|(?:^|[;{]\s*)[_*][A-Za-z-]+\s*:|\\9\b|@-moz-document|zoom\s*:\s*1\b",
    re.I | re.M,
)
PHYSICAL_PROP_RE = re.compile(
    r"^\s*(?:margin|padding|border|inset)?-?(?:left|right|top|bottom)(?:-[a-z-]+)?\s*:|"
    r"^\s*(?:left|right|top|bottom|width|height|min-width|max-width|min-height|max-height)\s*:",
    re.M,
)
LOGICAL_PROP_RE = re.compile(
    r"^\s*(?:margin|padding|border|inset)-(?:inline|block)(?:-[a-z-]+)?\s*:|"
    r"^\s*(?:inline-size|block-size|min-inline-size|max-inline-size|min-block-size|max-block-size)\s*:",
    re.M,
)
FIXED_WIDTH_RE = re.compile(r"\b(?:width|min-width)\s*:\s*(\d{3,})px\b", re.I)
FIXED_HEIGHT_RE = re.compile(r"\b(?:height|min-height)\s*:\s*(\d{3,})px\b", re.I)
OVERFLOW_HIDDEN_RE = re.compile(r"\boverflow(?:-[xy])?\s*:\s*hidden\b", re.I)
OUTLINE_NONE_RE = re.compile(r"\boutline\s*:\s*(?:none|0)\b", re.I)
USER_SELECT_NONE_RE = re.compile(r"\buser-select\s*:\s*none\b", re.I)
ALL_UNSET_RE = re.compile(r"\ball\s*:\s*(?:unset|initial)\b", re.I)
LAYER_RE = re.compile(r"@layer\b", re.I)
SCOPE_RE = re.compile(r"@scope\b", re.I)
CONTAINER_RE = re.compile(r"@container\b|\bcontainer-type\s*:|\bcontainer\s*:", re.I)
NATIVE_NESTING_RE = re.compile(r"(?m)^\s*&(?:[.#:\[>+~]|\s)")
STYLELINT_NAMES = {"stylelint.config.js", "stylelint.config.mjs", "stylelint.config.cjs", "stylelint.config.ts", ".stylelintrc", ".stylelintrc.json", ".stylelintrc.yml", ".stylelintrc.yaml", ".stylelintrc.js", ".stylelintrc.cjs"}
LOCKFILES = {"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"}


@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


@dataclass
class FileRecord:
    path: str
    suffix: str
    text: str


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect a repository for CSS architecture risks.")
    parser.add_argument("path", help="Project directory to inspect")
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--output", help="Write the report to a file instead of stdout")
    parser.add_argument("--fail-on", choices=("low", "medium", "high", "critical"), help="Exit 2 at or above severity")
    parser.add_argument("--max-files", type=int, default=20000, help="Maximum number of files to inspect")
    return parser.parse_args()


def iter_files(root: Path, max_files: int) -> Iterable[Path]:
    count = 0
    for current_root, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
        for name in files:
            if name.startswith(".") and name not in STYLELINT_NAMES:
                continue
            path = Path(current_root) / name
            if path.suffix.lower() not in TEXT_EXTENSIONS and name not in STYLELINT_NAMES | LOCKFILES | {"package.json", "composer.json", "bower.json", "Gruntfile.js", "gulpfile.js"}:
                continue
            try:
                if path.stat().st_size > MAX_FILE_BYTES:
                    continue
            except OSError:
                continue
            yield path
            count += 1
            if count >= max_files:
                return


def read_records(root: Path, max_files: int) -> list[FileRecord]:
    records: list[FileRecord] = []
    for path in iter_files(root, max_files):
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        records.append(FileRecord(str(path.relative_to(root)), path.suffix.lower(), text))
    return records


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


def first_match(text: str, pattern: re.Pattern[str]) -> tuple[int | None, str] | None:
    match = pattern.search(text)
    if not match:
        return None
    return line_number(text, match.start()), match.group(0).strip().replace("\n", " ")[:220]


def add_finding(findings: list[Finding], **kwargs: Any) -> None:
    findings.append(Finding(**kwargs))


def dependency_map(records: list[FileRecord]) -> dict[str, str]:
    deps: dict[str, str] = {}
    for record in records:
        if Path(record.path).name != "package.json":
            continue
        try:
            data = json.loads(record.text)
        except json.JSONDecodeError:
            continue
        for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
            values = data.get(section, {})
            if isinstance(values, dict):
                for name, version in values.items():
                    deps[str(name)] = str(version)
    return deps


def selector_candidates(text: str) -> Iterable[tuple[str, int]]:
    # Heuristic parser for authored selectors. It favors line-oriented source
    # and intentionally skips at-rules and declaration-only fragments.
    cleaned = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
    for match in re.finditer(r"(?m)^\s*([^@\n{}][^{}\n]*?)\s*\{", cleaned):
        raw = match.group(1).strip()
        if not raw or raw.startswith(("//", "$")):
            continue
        # A declaration before the brace is not a selector.
        if re.match(r"^[A-Za-z-]+\s*:", raw) and not any(ch in raw for ch in ".#[&>+~"):
            continue
        yield raw, line_number(cleaned, match.start(1))


def split_selector_list(raw: str) -> list[str]:
    # Good enough for architecture heuristics; commas inside functions are kept
    # by tracking parentheses and brackets.
    parts: list[str] = []
    current: list[str] = []
    depth = 0
    for char in raw:
        if char in "([":
            depth += 1
        elif char in ")]" and depth:
            depth -= 1
        if char == "," and depth == 0:
            value = "".join(current).strip()
            if value:
                parts.append(value)
            current = []
        else:
            current.append(char)
    value = "".join(current).strip()
    if value:
        parts.append(value)
    return parts


def selector_metrics(selector: str) -> tuple[int, int, int, int]:
    ids = len(ID_SELECTOR_RE.findall(selector))
    classes = len(CLASS_SELECTOR_RE.findall(selector)) + len(ATTR_SELECTOR_RE.findall(selector)) + len(PSEUDO_CLASS_RE.findall(selector))
    # Approximate combinator/depth count. Whitespace inside functions is removed.
    normalized = re.sub(r"\([^)]*\)", "", selector)
    explicit = len(re.findall(r"[>+~]", normalized))
    descendant = len(re.findall(r"\s+", normalized.strip()))
    depth = explicit + descendant + 1
    return ids, classes, depth, len(selector)


def scan(records: list[FileRecord], root: Path) -> tuple[list[Finding], dict[str, Any]]:
    findings: list[Finding] = []
    deps = dependency_map(records)
    style_records = [r for r in records if r.suffix in STYLE_EXTENSIONS]
    markup_records = [r for r in records if r.suffix in MARKUP_EXTENSIONS]
    script_records = [r for r in records if r.suffix in SCRIPT_EXTENSIONS]

    technologies: set[str] = set()
    suffix_names = {r.suffix for r in style_records}
    if ".css" in suffix_names: technologies.add("CSS")
    if ".scss" in suffix_names or ".sass" in suffix_names: technologies.add("Sass")
    if ".less" in suffix_names: technologies.add("Less")
    if ".pcss" in suffix_names or ".postcss" in suffix_names or "postcss" in deps: technologies.add("PostCSS")
    if any(".module." in r.path for r in style_records): technologies.add("CSS Modules")

    dep_tech = {
        "styled-components": "styled-components",
        "@emotion/react": "Emotion",
        "@emotion/styled": "Emotion",
        "@vanilla-extract/css": "vanilla-extract",
        "tailwindcss": "Tailwind CSS",
        "bootstrap": "Bootstrap",
        "less": "Less",
        "sass": "Dart Sass",
        "node-sass": "Node Sass",
        "stylelint": "Stylelint",
    }
    for dep, label in dep_tech.items():
        if dep in deps:
            technologies.add(label)
    if any(CSS_IN_JS_RE.search(r.text) for r in script_records):
        technologies.add("CSS-in-JS")

    metrics: dict[str, Any] = {
        "files_scanned": len(records),
        "style_files": len(style_records),
        "style_bytes": sum(len(r.text.encode("utf-8", errors="ignore")) for r in style_records),
        "technologies": sorted(technologies),
        "important_count": 0,
        "id_selector_count": 0,
        "custom_property_definitions": 0,
        "custom_property_uses": 0,
        "media_query_count": 0,
        "layer_usage": 0,
        "scope_usage": 0,
        "container_query_usage": 0,
        "physical_property_count": 0,
        "logical_property_count": 0,
    }

    # Dependency risks.
    if "node-sass" in deps:
        add_finding(findings, rule_id="CSS001", title="Node Sass dependency is end-of-life", severity="critical", confidence="high", area="preprocessor", file="package.json", line=None, evidence=f"node-sass {deps['node-sass']}", impact="The compiler is unmaintained and increasingly incompatible with current Sass and CSS syntax.", remediation="Migrate to Dart Sass in a separate, reproducible tranche; compare warnings and emitted CSS before removing Node Sass.", manual_check="Confirm transitive build tooling, native binary assumptions, and CI images.")
    if any(name.lower() in {"libsass", "sassc"} for name in deps):
        name = next(name for name in deps if name.lower() in {"libsass", "sassc"})
        add_finding(findings, rule_id="CSS002", title="LibSass-based dependency detected", severity="high", confidence="high", area="preprocessor", file="package.json", line=None, evidence=f"{name} {deps[name]}", impact="LibSass reached end-of-life and does not implement current Sass language behavior.", remediation="Plan migration to Dart Sass or a maintained embedded implementation with output comparison and visual regression.", manual_check="Check whether the dependency is actually used by the production build.")

    color_occurrences: dict[str, list[tuple[str, int]]] = collections.defaultdict(list)
    dimension_occurrences: dict[str, list[tuple[str, int]]] = collections.defaultdict(list)
    media_occurrences: dict[str, list[tuple[str, int]]] = collections.defaultdict(list)
    selector_occurrences: dict[str, list[tuple[str, int]]] = collections.defaultdict(list)
    keyframe_occurrences: dict[str, list[tuple[str, int]]] = collections.defaultdict(list)
    custom_defs: dict[str, list[tuple[str, int]]] = collections.defaultdict(list)
    custom_uses: dict[str, list[tuple[str, int, bool]]] = collections.defaultdict(list)
    z_values: dict[int, list[tuple[str, int]]] = collections.defaultdict(list)

    for record in style_records:
        text = record.text
        path = record.path
        metrics["important_count"] += len(IMPORTANT_RE.findall(text))
        metrics["custom_property_definitions"] += len(CUSTOM_PROP_DEF_RE.findall(text))
        metrics["custom_property_uses"] += len(CUSTOM_PROP_USE_RE.findall(text))
        metrics["media_query_count"] += len(MEDIA_RE.findall(text))
        metrics["layer_usage"] += len(LAYER_RE.findall(text))
        metrics["scope_usage"] += len(SCOPE_RE.findall(text))
        metrics["container_query_usage"] += len(CONTAINER_RE.findall(text))
        metrics["physical_property_count"] += len(PHYSICAL_PROP_RE.findall(text))
        metrics["logical_property_count"] += len(LOGICAL_PROP_RE.findall(text))

        important_count = len(IMPORTANT_RE.findall(text))
        if important_count >= 15 or (important_count >= 5 and important_count / max(1, text.count(";")) > 0.08):
            match = first_match(text, IMPORTANT_RE)
            add_finding(findings, rule_id="CSS010", title="High concentration of !important declarations", severity="high" if important_count >= 30 else "medium", confidence="high", area="cascade", file=path, line=match[0] if match else None, evidence=f"{important_count} occurrences in this file", impact="Override behavior becomes dependent on importance and source order, making component ownership and user overrides harder to reason about.", remediation="Map the competing rules, introduce explicit boundaries or layers, and remove importance incrementally with regression tests.", manual_check="Determine which uses are intentional utility or accessibility contracts before changing them.")

        legacy = first_match(text, LEGACY_HACK_RE)
        if legacy:
            add_finding(findings, rule_id="CSS011", title="Legacy browser CSS hack detected", severity="high", confidence="high", area="compatibility", file=path, line=legacy[0], evidence=legacy[1], impact="The rule may depend on obsolete engines, parser quirks, or unsupported behavior.", remediation="Verify the supported browser policy and isolate or remove the hack after equivalent-browser regression testing.", manual_check="Confirm whether an embedded legacy webview still requires the behavior.")

        if record.suffix in {".scss", ".sass"}:
            sass_imports = list(SASS_IMPORT_RE.finditer(text))
            if sass_imports:
                m = sass_imports[0]
                add_finding(findings, rule_id="CSS012", title="Deprecated Sass @import usage", severity="high", confidence="high", area="preprocessor", file=path, line=line_number(text, m.start()), evidence=m.group(0).strip()[:220], impact="Sass @import uses a global namespace, can emit duplicate CSS, and is deprecated in Dart Sass.", remediation="Use the official Sass module migrator in dry-run mode, then move to @use and @forward with emitted-CSS comparison.", manual_check="Distinguish Sass imports from plain CSS imports and inspect third-party compatibility.")
            extend = first_match(text, SASS_EXTEND_RE)
            if extend:
                add_finding(findings, rule_id="CSS013", title="Non-placeholder Sass @extend detected", severity="medium", confidence="medium", area="preprocessor", file=path, line=extend[0], evidence=extend[1], impact="Extending concrete selectors can couple distant modules and generate unexpected selector groups.", remediation="Prefer placeholder selectors, mixins, or shared component classes; inspect generated CSS before changing.", manual_check="Review the complete selector output and whether the extension is a public contract.")

        if record.suffix == ".css":
            css_import = first_match(text, CSS_IMPORT_RE)
            if css_import:
                add_finding(findings, rule_id="CSS014", title="Runtime CSS @import detected", severity="medium", confidence="high", area="delivery", file=path, line=css_import[0], evidence=css_import[1], impact="CSS imports can create additional render-blocking fetch chains and obscure ordering.", remediation="Bundle or link styles explicitly when compatible with the deployment model; preserve layer and source order.", manual_check="Check whether the import is intentionally handled by a build tool rather than delivered to browsers.")
            nesting = first_match(text, NATIVE_NESTING_RE)
            if nesting:
                add_finding(findings, rule_id="CSS015", title="Native CSS nesting requires target verification", severity="low", confidence="high", area="compatibility", file=path, line=nesting[0], evidence=nesting[1], impact="Source and build support do not guarantee support in every target browser, and nesting can increase specificity.", remediation="Verify the browser/build policy, inspect emitted selectors, and provide compilation or fallback where required.", manual_check="Confirm whether the file is transformed by PostCSS before delivery.")

        scope = first_match(text, SCOPE_RE)
        if scope:
            add_finding(findings, rule_id="CSS016", title="@scope usage requires progressive-support review", severity="low", confidence="high", area="compatibility", file=path, line=scope[0], evidence=scope[1], impact="@scope is a newer cascade feature and is not equivalent to Shadow DOM encapsulation.", remediation="Verify current target support and define a fallback or compilation boundary.", manual_check="Test overlap with unscoped rules and nested scope limits.")

        outline = first_match(text, OUTLINE_NONE_RE)
        if outline:
            add_finding(findings, rule_id="CSS017", title="Focus outline removal detected", severity="high", confidence="medium", area="accessibility", file=path, line=outline[0], evidence=outline[1], impact="Keyboard users may lose visible focus if an equally visible replacement is not provided.", remediation="Use :focus-visible and provide a high-contrast replacement that survives forced-colors mode.", manual_check="Inspect the full rule and interactive states to confirm whether a replacement exists.")

        all_unset = first_match(text, ALL_UNSET_RE)
        if all_unset:
            add_finding(findings, rule_id="CSS018", title="Broad all reset detected", severity="medium", confidence="medium", area="accessibility", file=path, line=all_unset[0], evidence=all_unset[1], impact="Resetting all properties can remove native accessibility, writing-mode, form, and user-agent behavior.", remediation="Reset only required properties or rebuild native states explicitly and test keyboard, forced colors, zoom, and form semantics.", manual_check="Check whether the rule applies only to a controlled decorative element.")

        fixed_width = first_match(text, FIXED_WIDTH_RE)
        if fixed_width and OVERFLOW_HIDDEN_RE.search(text):
            add_finding(findings, rule_id="CSS019", title="Rigid width combined with hidden overflow", severity="high", confidence="medium", area="responsive", file=path, line=fixed_width[0], evidence=f"{fixed_width[1]} with overflow hidden in the same stylesheet", impact="Content may clip under zoom, translation, text enlargement, or narrow containers.", remediation="Use intrinsic or max sizing, wrapping, and component-level overflow behavior; test at 200% text and 400% zoom.", manual_check="Confirm whether the declarations apply to the same component and whether clipping is essential.")
        elif fixed_width:
            add_finding(findings, rule_id="CSS020", title="Large fixed inline dimension detected", severity="medium", confidence="low", area="responsive", file=path, line=fixed_width[0], evidence=fixed_width[1], impact="The component may not reflow in narrow containers or under zoom and localization.", remediation="Prefer max-inline-size, minmax(), flex/grid constraints, or a documented overflow strategy.", manual_check="Render the affected component in narrow containers before changing.")

        fixed_height = first_match(text, FIXED_HEIGHT_RE)
        if fixed_height and OVERFLOW_HIDDEN_RE.search(text):
            add_finding(findings, rule_id="CSS021", title="Rigid height combined with hidden overflow", severity="high", confidence="medium", area="responsive", file=path, line=fixed_height[0], evidence=f"{fixed_height[1]} with overflow hidden in the same stylesheet", impact="Text, validation messages, translations, or dynamic content may be clipped.", remediation="Prefer min-block-size or content-driven sizing and test content extremes.", manual_check="Confirm whether the height is limited to media with a valid alternate representation.")

        if USER_SELECT_NONE_RE.search(text):
            match = first_match(text, USER_SELECT_NONE_RE)
            add_finding(findings, rule_id="CSS022", title="Text selection disabled", severity="medium", confidence="medium", area="accessibility", file=path, line=match[0] if match else None, evidence=match[1] if match else "user-select: none", impact="Users may be unable to select, copy, translate, or use assistive workflows with text.", remediation="Limit user-select:none to drag handles or decorative controls and keep meaningful text selectable.", manual_check="Confirm the selector and interaction purpose.")

        for match in CUSTOM_PROP_DEF_RE.finditer(text):
            custom_defs[match.group(1)].append((path, line_number(text, match.start())))
        no_fallback_names = {m.group(1) for m in CUSTOM_PROP_NO_FALLBACK_RE.finditer(text)}
        for match in CUSTOM_PROP_USE_RE.finditer(text):
            custom_uses[match.group(1)].append((path, line_number(text, match.start()), match.group(1) in no_fallback_names))

        for match in HEX_RE.finditer(text):
            color_occurrences[match.group(0).lower()].append((path, line_number(text, match.start())))
        for match in COLOR_FN_RE.finditer(text):
            normalized = re.sub(r"\s+", "", match.group(0).lower())
            color_occurrences[normalized].append((path, line_number(text, match.start())))
        for match in DIMENSION_RE.finditer(text):
            value = f"{match.group(1)}{match.group(2).lower()}"
            if value in {"0px", "0rem", "0em", "1px", "100vh", "100vw"}:
                continue
            dimension_occurrences[value].append((path, line_number(text, match.start())))
        for match in Z_INDEX_RE.finditer(text):
            z_values[int(match.group(1))].append((path, line_number(text, match.start())))
        for match in MEDIA_RE.finditer(text):
            condition = re.sub(r"\s+", " ", match.group(1).strip().lower())
            media_occurrences[condition].append((path, line_number(text, match.start())))
        for match in KEYFRAMES_RE.finditer(text):
            keyframe_occurrences[match.group(1)].append((path, line_number(text, match.start())))

        for raw, line in selector_candidates(text):
            for selector in split_selector_list(raw):
                normalized = re.sub(r"\s+", " ", selector.strip())
                if not normalized:
                    continue
                selector_occurrences[normalized].append((path, line))
                ids, classes, depth, length = selector_metrics(normalized)
                metrics["id_selector_count"] += ids
                if ids:
                    add_finding(findings, rule_id="CSS030", title="ID selector increases coupling and specificity", severity="medium", confidence="high", area="selectors", file=path, line=line, evidence=normalized[:220], impact="ID selectors are difficult to override and often couple styles to unique page markup.", remediation="For new code, use a component class, attribute, or scoped adapter. Preserve an ID selector only for an immovable legacy contract.", manual_check="Confirm whether the selector is inside an SVG fragment or required external integration.")
                if depth >= 6 or classes >= 7 or length > 180:
                    add_finding(findings, rule_id="CSS031", title="High-complexity selector detected", severity="high" if depth >= 9 or classes >= 10 else "medium", confidence="medium", area="selectors", file=path, line=line, evidence=normalized[:220], impact="The rule is coupled to DOM ancestry or carries high specificity, increasing regression risk during markup changes.", remediation="Introduce a component or adapter boundary and reduce structural depth without changing behavior broadly.", manual_check="Review generated selectors and whether preprocessor nesting produced the complexity.")

    # Cross-file and repository-level findings.
    for name, uses in custom_uses.items():
        if name not in custom_defs and any(no_fallback for _, _, no_fallback in uses):
            path, line, _ = uses[0]
            add_finding(findings, rule_id="CSS040", title="Custom property used without local definition or fallback", severity="medium", confidence="low", area="tokens", file=path, line=line, evidence=name, impact="The declaration may become invalid when the external theme or host does not define the property.", remediation="Document the external contract or provide a valid fallback at the use site.", manual_check="Confirm whether the property is intentionally supplied by a host, design-system package, or Shadow DOM consumer.")

    repeated_colors = [(value, locations) for value, locations in color_occurrences.items() if len(locations) >= 6]
    if repeated_colors:
        value, locations = sorted(repeated_colors, key=lambda item: len(item[1]), reverse=True)[0]
        add_finding(findings, rule_id="CSS041", title="Frequently repeated hard-coded color", severity="medium", confidence="medium", area="tokens", file=locations[0][0], line=locations[0][1], evidence=f"{value} appears {len(locations)} times", impact="Theme changes and contrast fixes may require scattered edits and produce inconsistent states.", remediation="Determine whether the value represents a semantic decision; if so, introduce a semantic token rather than a raw palette alias in consumer code.", manual_check="Do not tokenize values that are coincidentally equal but semantically different.")

    repeated_dims = [(value, locations) for value, locations in dimension_occurrences.items() if len(locations) >= 12]
    if repeated_dims:
        value, locations = sorted(repeated_dims, key=lambda item: len(item[1]), reverse=True)[0]
        add_finding(findings, rule_id="CSS042", title="Frequently repeated hard-coded dimension", severity="low", confidence="low", area="tokens", file=locations[0][0], line=locations[0][1], evidence=f"{value} appears {len(locations)} times", impact="The value may represent an undocumented spacing, size, or breakpoint decision.", remediation="Classify semantic meaning before introducing a token; keep local layout values local when no shared decision exists.", manual_check="Check generated utility CSS before treating repetition as authoring debt.")

    duplicate_media = [(cond, locs) for cond, locs in media_occurrences.items() if len({p for p, _ in locs}) >= 4]
    if duplicate_media:
        cond, locs = sorted(duplicate_media, key=lambda item: len(item[1]), reverse=True)[0]
        add_finding(findings, rule_id="CSS043", title="Media query condition duplicated across many files", severity="medium", confidence="medium", area="responsive", file=locs[0][0], line=locs[0][1], evidence=f"@media {cond} occurs in {len({p for p, _ in locs})} files", impact="Breakpoint behavior may be fragmented and difficult to change safely.", remediation="Decide whether the condition is a page-level contract, generated mixin, or component concern; prefer container queries for reusable components.", manual_check="Do not centralize media queries if code splitting or component ownership would become worse.")

    duplicate_selectors = [(sel, locs) for sel, locs in selector_occurrences.items() if len({p for p, _ in locs}) >= 3 and not sel.startswith(":root")]
    if duplicate_selectors:
        sel, locs = sorted(duplicate_selectors, key=lambda item: len(item[1]), reverse=True)[0]
        add_finding(findings, rule_id="CSS044", title="Selector defined across multiple stylesheets", severity="medium", confidence="low", area="ownership", file=locs[0][0], line=locs[0][1], evidence=f"{sel[:180]} appears in {len({p for p, _ in locs})} files", impact="Ownership and final source order may be unclear, causing route-specific regressions.", remediation="Map declaration intent and consolidate or assign explicit layer and component ownership.", manual_check="Theme or state-specific repetitions can be valid; inspect declarations and conditions.")

    duplicate_keyframes = [(name, locs) for name, locs in keyframe_occurrences.items() if len(locs) > 1]
    if duplicate_keyframes:
        name, locs = duplicate_keyframes[0]
        add_finding(findings, rule_id="CSS045", title="Duplicate keyframe name detected", severity="high", confidence="high", area="animation", file=locs[0][0], line=locs[0][1], evidence=f"@keyframes {name} defined {len(locs)} times", impact="Keyframe resolution can depend on load order and cause unrelated components to change animation.", remediation="Namespace keyframes or scope generation through the component build system; remove duplicate definitions after regression testing.", manual_check="Confirm whether definitions are mutually exclusive build targets.")

    if len(z_values) >= 8 or any(abs(value) >= 10000 for value in z_values):
        sample = max(z_values, key=lambda value: abs(value))
        path, line = z_values[sample][0]
        add_finding(findings, rule_id="CSS046", title="Uncontrolled z-index scale", severity="medium", confidence="medium", area="stacking", file=path, line=line, evidence=f"{len(z_values)} unique numeric values; sample z-index {sample}", impact="Overlay behavior may rely on escalating numbers rather than explicit stacking contexts and top-layer primitives.", remediation="Inventory stacking contexts and define a small semantic z-index scale; use native top-layer components where applicable.", manual_check="Inspect transforms, opacity, containment, positioned ancestors, dialogs, and popovers.")

    inline_count = 0
    inline_sample: tuple[str, int, str] | None = None
    for record in markup_records:
        for pattern in (INLINE_STYLE_RE, JSX_STYLE_RE):
            for match in pattern.finditer(record.text):
                inline_count += 1
                if inline_sample is None:
                    inline_sample = (record.path, line_number(record.text, match.start()), match.group(0)[:180])
    if inline_count >= 10 and inline_sample:
        add_finding(findings, rule_id="CSS047", title="Widespread inline style usage", severity="medium", confidence="medium", area="ownership", file=inline_sample[0], line=inline_sample[1], evidence=f"{inline_count} inline style signals; sample {inline_sample[2]}", impact="Inline declarations bypass normal stylesheet ownership and are difficult to theme or override without importance.", remediation="Keep genuinely dynamic values inline through documented custom properties; move stable visual decisions into owned styles.", manual_check="Differentiate generated email markup, safe dynamic geometry, and application styling.")

    style_system_count = sum(1 for label in ["Sass", "Less", "CSS Modules", "CSS-in-JS", "Tailwind CSS", "Bootstrap"] if label in technologies)
    if style_system_count >= 4:
        add_finding(findings, rule_id="CSS048", title="Multiple styling systems require explicit boundaries", severity="high", confidence="high", area="architecture", file="package.json" if deps else "repository", line=None, evidence=", ".join(sorted(technologies)), impact="Several systems may compete for the same elements, duplicate tokens, and inject styles in different orders.", remediation="Assign ownership by route or component and define a shared token, layer, reset, and overlay strategy before further migration.", manual_check="Confirm which dependencies are active in production rather than historical.")

    has_stylelint = any(Path(r.path).name in STYLELINT_NAMES for r in records) or "stylelint" in deps
    if len(style_records) >= 8 and not has_stylelint:
        add_finding(findings, rule_id="CSS049", title="No Stylelint configuration detected", severity="medium", confidence="high", area="quality", file="repository", line=None, evidence=f"{len(style_records)} style files scanned", impact="Invalid syntax, deprecated constructs, and architecture regressions may enter without a repeatable policy gate.", remediation="Add a parser-aware linter in report-only mode first, then ratchet rules for changed or migrated areas.", manual_check="Check whether another CSS linter is configured outside the scanned repository.")

    if "Sass" in technologies and "Dart Sass" not in technologies and "Node Sass" not in technologies:
        add_finding(findings, rule_id="CSS050", title="Sass compiler implementation is not explicit", severity="low", confidence="low", area="preprocessor", file="repository", line=None, evidence="Sass source files found without a direct sass or node-sass dependency", impact="Build behavior may depend on a global, transitive, or platform-specific compiler.", remediation="Pin and document the maintained compiler and lockfile used by CI and production builds.", manual_check="Inspect non-Node build systems and parent repositories.")

    if deps and not any(Path(r.path).name in LOCKFILES for r in records):
        add_finding(findings, rule_id="CSS051", title="Package dependencies found without a lockfile", severity="medium", confidence="medium", area="build", file="package.json", line=None, evidence="package.json dependencies detected; no common lockfile scanned", impact="CSS compiler and plugin output may change between installations.", remediation="Commit the package-manager lockfile or document an equivalent reproducible dependency mechanism.", manual_check="Check whether the lockfile exists in a parent monorepo outside the scan root.")

    if len(style_records) >= 12 and metrics["layer_usage"] == 0 and ("Bootstrap" in technologies or "CSS-in-JS" in technologies or len(style_records) >= 25):
        add_finding(findings, rule_id="CSS052", title="Large mixed cascade has no explicit layer usage", severity="low", confidence="low", area="cascade", file="repository", line=None, evidence=f"{len(style_records)} style files; technologies: {', '.join(sorted(technologies))}", impact="Precedence may depend entirely on import and runtime injection order.", remediation="Map the effective cascade first, then evaluate whether cascade layers can create safer vendor, base, component, utility, and override boundaries.", manual_check="Layers are not mandatory; verify whether modules or Shadow DOM already provide sufficient isolation.")

    if metrics["physical_property_count"] >= 25 and metrics["logical_property_count"] == 0:
        add_finding(findings, rule_id="CSS053", title="Directional styling uses only physical properties", severity="medium", confidence="medium", area="internationalization", file="repository", line=None, evidence=f"{metrics['physical_property_count']} physical directional declarations; no logical declarations detected", impact="RTL or alternate writing-mode support may require duplicated overrides and can regress during layout changes.", remediation="Introduce logical properties component by component and test LTR and RTL; retain physical properties for genuinely physical effects.", manual_check="Confirm whether the product has an RTL or writing-mode requirement.")

    findings.sort(key=lambda f: (-SEVERITY_RANK[f.severity], f.file, f.line or 0, f.rule_id))
    summary_counts = collections.Counter(f.severity for f in findings)
    metrics["finding_counts"] = {key: summary_counts.get(key, 0) for key in ("critical", "high", "medium", "low", "info")}
    metrics["dependencies"] = dict(sorted(deps.items()))
    return findings, metrics


def markdown_report(root: Path, findings: list[Finding], metrics: dict[str, Any]) -> str:
    counts = metrics["finding_counts"]
    lines = [
        "# CSS Architecture Inspection",
        "",
        f"- **Project:** `{root}`",
        f"- **Files scanned:** {metrics['files_scanned']}",
        f"- **Style files:** {metrics['style_files']}",
        f"- **Style source bytes:** {metrics['style_bytes']}",
        f"- **Technologies:** {', '.join(metrics['technologies']) or 'None detected'}",
        f"- **Findings:** {counts['critical']} critical, {counts['high']} high, {counts['medium']} medium, {counts['low']} low",
        "",
        "## Metrics",
        "",
        "| Metric | Value |",
        "| --- | ---: |",
        f"| `!important` occurrences | {metrics['important_count']} |",
        f"| ID selector signals | {metrics['id_selector_count']} |",
        f"| Custom property definitions | {metrics['custom_property_definitions']} |",
        f"| Custom property uses | {metrics['custom_property_uses']} |",
        f"| Media queries | {metrics['media_query_count']} |",
        f"| Cascade layer signals | {metrics['layer_usage']} |",
        f"| `@scope` signals | {metrics['scope_usage']} |",
        f"| Container-query signals | {metrics['container_query_usage']} |",
        f"| Physical directional properties | {metrics['physical_property_count']} |",
        f"| Logical properties | {metrics['logical_property_count']} |",
        "",
        "> Static evidence only. Resolve the runtime cascade, computed styles, accessibility, visual behavior, browser support, and unused CSS manually or with browser-based tooling.",
        "",
        "## Findings",
        "",
    ]
    if not findings:
        lines.append("No configured static risk signals were found.")
        return "\n".join(lines) + "\n"
    for index, finding in enumerate(findings, 1):
        location = finding.file
        if finding.line:
            location += f":{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}`",
            f"- **Impact:** {finding.impact}",
            f"- **Remediation:** {finding.remediation}",
            f"- **Manual check:** {finding.manual_check}",
            "",
        ])
    return "\n".join(lines)


def json_report(root: Path, findings: list[Finding], metrics: dict[str, Any]) -> str:
    payload = {
        "tool": "inspect_css_architecture.py",
        "project": str(root),
        "limitations": [
            "Static source inspection only",
            "Does not resolve runtime cascade or computed styles",
            "Does not prove unused CSS",
            "Does not establish accessibility or visual correctness",
        ],
        "metrics": metrics,
        "findings": [asdict(finding) for finding in findings],
    }
    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() or not root.is_dir():
        print(f"error: not a directory: {root}", file=sys.stderr)
        return 1
    records = read_records(root, args.max_files)
    findings, metrics = scan(records, root)
    output = markdown_report(root, findings, metrics) if args.format == "markdown" else json_report(root, findings, metrics)
    if args.output:
        Path(args.output).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())
