#!/usr/bin/env python3
"""Static design-system inventory and risk scanner.

The scanner uses only the Python standard library. It reports repository evidence
that can guide a design-system audit. It does not render components, resolve the
runtime cascade, establish accessibility conformance, or prove API stability.
"""

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",
}

TEXT_EXTENSIONS = {
    ".css",
    ".scss",
    ".sass",
    ".less",
    ".html",
    ".htm",
    ".js",
    ".jsx",
    ".mjs",
    ".cjs",
    ".ts",
    ".tsx",
    ".vue",
    ".svelte",
    ".php",
    ".cfm",
    ".cfml",
    ".jsp",
    ".aspx",
    ".cshtml",
    ".razor",
    ".json",
    ".md",
    ".mdx",
    ".yaml",
    ".yml",
}

STYLE_EXTENSIONS = {".css", ".scss", ".sass", ".less", ".vue", ".svelte"}
COMPONENT_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"}
MAX_FILE_BYTES = 2_000_000

CSS_VAR_RE = re.compile(r"(?m)(--[A-Za-z0-9_-]+)\s*:")
SASS_VAR_RE = re.compile(r"(?m)^\s*(\$[A-Za-z0-9_-]+)\s*:")
LESS_VAR_RE = re.compile(r"(?m)^\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)
CLASS_DECL_RE = re.compile(r"(?m)(?:^|[,{\s])\.([A-Za-z_-][A-Za-z0-9_-]*)")
TOKEN_REF_RE = re.compile(r"^\{([^{}]+)\}$")
DEPRECATED_RE = re.compile(r"\bdeprecated\b|@deprecated", re.I)

SEVERITY_RANK = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}


@dataclass
class Finding:
    rule_id: str
    title: str
    severity: str
    confidence: str
    category: str
    evidence: str
    remediation: str
    files: list[str]


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


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect a repository for design-system structure and 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 with status 2 when a finding at or above this severity exists",
    )
    parser.add_argument("--max-files", type=int, default=20000, help="Maximum 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 {".changeset"}:
                continue
            path = Path(current_root) / name
            if path.suffix.lower() not in TEXT_EXTENSIONS and name not in {
                "package.json",
                "composer.json",
                "CHANGELOG",
                "CHANGELOG.md",
            }:
                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 token_prefix(name: str) -> str:
    value = name[2:] if name.startswith("--") else name.lstrip("$@")
    parts = re.split(r"[-_.]", value)
    return parts[0].lower() if parts and parts[0] else value.lower()


def normalize_color(value: str) -> str:
    return re.sub(r"\s+", "", value.strip().lower())


def normalize_dimension(value: str, unit: str) -> str:
    try:
        number = float(value)
        if number == int(number):
            value = str(int(number))
        else:
            value = str(number)
    except ValueError:
        pass
    return f"{value}{unit.lower()}"


def walk_token_tree(node: Any, path: tuple[str, ...], result: dict[str, dict[str, Any]]) -> None:
    if not isinstance(node, dict):
        return
    if "$value" in node:
        result[".".join(path)] = node
        return
    for key, value in node.items():
        if key.startswith("$"):
            continue
        walk_token_tree(value, path + (key,), result)


def inspect_json_tokens(record: FileRecord) -> tuple[dict[str, dict[str, Any]], str | None]:
    try:
        data = json.loads(record.text)
    except json.JSONDecodeError as exc:
        return {}, f"{record.path}: invalid JSON at line {exc.lineno}"
    tokens: dict[str, dict[str, Any]] = {}
    walk_token_tree(data, (), tokens)
    return tokens, None


def find_component_candidates(records: list[FileRecord]) -> list[str]:
    candidates: list[str] = []
    for record in records:
        path = Path(record.path)
        path_lower = record.path.lower()
        if record.suffix not in COMPONENT_EXTENSIONS:
            continue
        if any(marker in path_lower for marker in (".test.", ".spec.", ".stories.", "/tests/", "/__tests__/")):
            continue
        if "/components/" in f"/{path_lower}" or path.parent.name.lower() in {"components", "ui", "widgets"}:
            candidates.append(record.path)
            continue
        if re.match(r"^[A-Z][A-Za-z0-9]+\.(?:jsx?|tsx?|vue|svelte)$", path.name):
            candidates.append(record.path)
    return sorted(set(candidates))


def inspect(root: Path, records: list[FileRecord]) -> dict[str, Any]:
    css_vars: collections.Counter[str] = collections.Counter()
    sass_vars: collections.Counter[str] = collections.Counter()
    less_vars: collections.Counter[str] = collections.Counter()
    raw_colors: collections.defaultdict[str, list[str]] = collections.defaultdict(list)
    raw_dimensions: collections.defaultdict[str, list[str]] = collections.defaultdict(list)
    z_indexes: collections.defaultdict[str, list[str]] = collections.defaultdict(list)
    class_names: collections.Counter[str] = collections.Counter()
    token_files: dict[str, dict[str, dict[str, Any]]] = {}
    invalid_json: list[str] = []
    bootstrap_files: list[str] = []
    theme_files: list[str] = []
    reduced_motion_files: list[str] = []
    forced_colors_files: list[str] = []
    important_files: list[str] = []
    deprecated_files: list[str] = []
    icon_font_files: list[str] = []
    stories: list[str] = []
    tests: list[str] = []
    docs: list[str] = []
    changelogs: list[str] = []
    package_files: list[str] = []
    package_versions: list[dict[str, str]] = []
    tools: set[str] = set()

    style_dictionary_markers = ("style-dictionary", "@tokens-studio", "terrazzo", "theo")
    storybook_markers = ("@storybook/", "storybook")

    for record in records:
        lower_path = record.path.lower()
        text_lower = record.text.lower()
        path_obj = Path(record.path)

        if path_obj.name.lower().startswith("changelog") or "/.changeset/" in f"/{lower_path}":
            changelogs.append(record.path)
        if record.suffix in {".md", ".mdx"} or "/docs/" in f"/{lower_path}":
            docs.append(record.path)
        if ".stories." in lower_path or lower_path.endswith(".story.mdx"):
            stories.append(record.path)
        if any(marker in lower_path for marker in (".test.", ".spec.", "/tests/", "/__tests__/")):
            tests.append(record.path)
        if DEPRECATED_RE.search(record.text):
            deprecated_files.append(record.path)
        if "@font-face" in text_lower and any(marker in text_lower for marker in ("icon", "glyph", "fontawesome", "bootstrap-icons")):
            icon_font_files.append(record.path)

        if path_obj.name == "package.json":
            package_files.append(record.path)
            try:
                package = json.loads(record.text)
            except json.JSONDecodeError:
                package = {}
            if isinstance(package, dict):
                package_versions.append(
                    {
                        "file": record.path,
                        "name": str(package.get("name", "")),
                        "version": str(package.get("version", "")),
                    }
                )
                deps: dict[str, Any] = {}
                for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
                    value = package.get(key)
                    if isinstance(value, dict):
                        deps.update(value)
                for dep in deps:
                    dep_lower = dep.lower()
                    if any(marker in dep_lower for marker in style_dictionary_markers):
                        tools.add(dep)
                    if any(marker in dep_lower for marker in storybook_markers):
                        tools.add(dep)
                    if dep_lower == "bootstrap" or dep_lower.startswith("bootstrap"):
                        tools.add(dep)
                    if dep_lower in {"sass", "less", "postcss", "tailwindcss", "lit", "@lit/react"}:
                        tools.add(dep)

        if record.suffix == ".json":
            tokens, error = inspect_json_tokens(record)
            if error:
                invalid_json.append(error)
            if tokens:
                token_files[record.path] = tokens

        if record.suffix in STYLE_EXTENSIONS:
            for name in CSS_VAR_RE.findall(record.text):
                css_vars[name] += 1
            for name in SASS_VAR_RE.findall(record.text):
                sass_vars[name] += 1
            for name in LESS_VAR_RE.findall(record.text):
                less_vars[name] += 1
            for value in HEX_RE.findall(record.text):
                raw_colors[normalize_color(value)].append(record.path)
            for value in COLOR_FN_RE.findall(record.text):
                raw_colors[normalize_color(value)].append(record.path)
            for number, unit in DIMENSION_RE.findall(record.text):
                raw_dimensions[normalize_dimension(number, unit)].append(record.path)
            for value in Z_INDEX_RE.findall(record.text):
                z_indexes[value].append(record.path)
            for class_name in CLASS_DECL_RE.findall(record.text):
                class_names[class_name] += 1
            if "!important" in text_lower:
                important_files.append(record.path)
            if any(marker in text_lower for marker in ("data-theme", "data-bs-theme", "prefers-color-scheme", "[theme", ".dark")):
                theme_files.append(record.path)
            if "prefers-reduced-motion" in text_lower:
                reduced_motion_files.append(record.path)
            if "forced-colors" in text_lower or "-ms-high-contrast" in text_lower:
                forced_colors_files.append(record.path)
            if any(marker in text_lower for marker in ("--bs-", "$theme-colors", "$enable-dark-mode", ".container-fluid", ".btn-primary")):
                bootstrap_files.append(record.path)

    all_tokens: dict[str, tuple[str, dict[str, Any]]] = {}
    for file_name, tokens in token_files.items():
        for path, token in tokens.items():
            all_tokens[path] = (file_name, token)

    unresolved_refs: list[tuple[str, str, str]] = []
    tokens_missing_description: list[tuple[str, str]] = []
    tokens_missing_type: list[tuple[str, str]] = []
    deprecated_tokens: list[tuple[str, str]] = []
    token_types: collections.Counter[str] = collections.Counter()
    token_values: collections.defaultdict[str, list[str]] = collections.defaultdict(list)

    for token_path, (file_name, token) in all_tokens.items():
        if not token.get("$description"):
            tokens_missing_description.append((file_name, token_path))
        token_type = token.get("$type")
        if token_type:
            token_types[str(token_type)] += 1
        else:
            tokens_missing_type.append((file_name, token_path))
        if token.get("$deprecated"):
            deprecated_tokens.append((file_name, token_path))
        value = token.get("$value")
        if isinstance(value, str):
            match = TOKEN_REF_RE.match(value.strip())
            if match:
                target = match.group(1)
                if target not in all_tokens:
                    unresolved_refs.append((file_name, token_path, target))
            else:
                token_values[normalize_color(value)].append(token_path)
        elif isinstance(value, (int, float, bool)):
            token_values[str(value)].append(token_path)

    component_candidates = find_component_candidates(records)
    css_prefixes = collections.Counter(token_prefix(name) for name in css_vars)
    class_prefixes = collections.Counter(
        re.split(r"[-_]", name)[0].lower() for name in class_names if re.split(r"[-_]", name)[0]
    )

    findings: list[Finding] = []

    def add(
        rule_id: str,
        title: str,
        severity: str,
        confidence: str,
        category: str,
        evidence: str,
        remediation: str,
        files: list[str] | None = None,
    ) -> None:
        findings.append(
            Finding(rule_id, title, severity, confidence, category, evidence, remediation, sorted(set(files or []))[:25])
        )

    if not token_files and not css_vars and not sass_vars and not less_vars:
        add(
            "DS001",
            "No reusable token or variable layer detected",
            "high" if component_candidates else "medium",
            "medium",
            "tokens",
            "The scan found no DTCG-style JSON tokens, CSS custom properties, Sass variables, or Less variables.",
            "Inventory repeated decisions and introduce a small primitive and semantic token layer before creating broad component APIs.",
        )

    repeated_colors = {value: paths for value, paths in raw_colors.items() if len(paths) >= 4}
    if repeated_colors:
        top = sorted(repeated_colors.items(), key=lambda item: len(item[1]), reverse=True)[:10]
        evidence = "; ".join(f"{value} x{len(paths)}" for value, paths in top)
        add(
            "DS002",
            "Repeated hard-coded colors may bypass semantic tokens",
            "medium",
            "medium",
            "tokens",
            evidence,
            "Classify repeated colors by intent. Replace stable shared decisions with semantic or component tokens, while leaving genuine local exceptions documented.",
            [path for _, paths in top for path in paths],
        )

    repeated_dimensions = {
        value: paths
        for value, paths in raw_dimensions.items()
        if len(paths) >= 8 and value not in {"0px", "1px", "100%"}
    }
    if repeated_dimensions:
        top = sorted(repeated_dimensions.items(), key=lambda item: len(item[1]), reverse=True)[:10]
        add(
            "DS003",
            "Repeated dimensions may represent undocumented spacing or sizing decisions",
            "low",
            "low",
            "tokens",
            "; ".join(f"{value} x{len(paths)}" for value, paths in top),
            "Review repeated dimensions by semantic role. Tokenize only reusable decisions and avoid replacing all numeric values mechanically.",
            [path for _, paths in top for path in paths],
        )

    if len([p for p, count in css_prefixes.items() if count >= 3]) >= 4:
        common = ", ".join(f"{p} ({count})" for p, count in css_prefixes.most_common(8))
        add(
            "DS004",
            "CSS custom properties use several competing prefix families",
            "medium",
            "medium",
            "architecture",
            common,
            "Define ownership and public prefixes. Map legacy variables through a compatibility layer instead of allowing uncontrolled parallel token vocabularies.",
        )

    if all_tokens and tokens_missing_description:
        ratio = len(tokens_missing_description) / max(len(all_tokens), 1)
        if ratio >= 0.25:
            add(
                "DS005",
                "Many interchange tokens lack descriptions",
                "medium" if ratio >= 0.6 else "low",
                "high",
                "tokens",
                f"{len(tokens_missing_description)} of {len(all_tokens)} tokens lack $description.",
                "Add descriptions that state the design decision and intended consumers, especially for semantic and component tokens.",
                [file_name for file_name, _ in tokens_missing_description],
            )

    if all_tokens and tokens_missing_type:
        ratio = len(tokens_missing_type) / max(len(all_tokens), 1)
        if ratio >= 0.25:
            add(
                "DS006",
                "Many interchange tokens lack explicit types",
                "medium",
                "high",
                "tokens",
                f"{len(tokens_missing_type)} of {len(all_tokens)} tokens lack $type.",
                "Add explicit token types at the token or group level and validate generated platform conversions.",
                [file_name for file_name, _ in tokens_missing_type],
            )

    if unresolved_refs:
        add(
            "DS007",
            "Token aliases reference missing token paths",
            "high",
            "high",
            "tokens",
            "; ".join(f"{path} -> {target}" for _, path, target in unresolved_refs[:15]),
            "Correct the alias paths or include the required token source. Fail the token build on unresolved references.",
            [file_name for file_name, _, _ in unresolved_refs],
        )

    if component_candidates and not stories:
        add(
            "DS008",
            "Reusable components have no isolated stories or examples detected",
            "medium",
            "medium",
            "documentation",
            f"Found {len(component_candidates)} component candidates and no *.stories.* files.",
            "Add isolated production examples for variants, states, themes, responsive behavior, long content, RTL, and accessibility testing. Use an equivalent catalog if Storybook is not appropriate.",
            component_candidates[:20],
        )

    if component_candidates and not tests:
        add(
            "DS009",
            "Reusable components have no colocated or repository tests detected",
            "high",
            "medium",
            "testing",
            f"Found {len(component_candidates)} component candidates and no common test file patterns.",
            "Add render, interaction, accessibility, and package contract tests. Verify whether tests live in an external repository before treating this as confirmed.",
            component_candidates[:20],
        )

    if component_candidates and len(docs) < 2:
        add(
            "DS010",
            "Component documentation appears limited",
            "medium",
            "low",
            "documentation",
            f"Found {len(component_candidates)} component candidates and {len(docs)} Markdown or docs files.",
            "Document purpose, non-usage, API, states, accessibility, responsive behavior, content constraints, themes, examples, and migration guidance.",
            component_candidates[:20],
        )

    if package_files and not changelogs:
        add(
            "DS011",
            "Package metadata exists without changelog or changeset evidence",
            "medium",
            "medium",
            "governance",
            f"Found {len(package_files)} package.json file(s) and no CHANGELOG or .changeset content.",
            "Define consumer-facing versioning, release notes, deprecation, and migration records. Confirm whether release notes are maintained outside the repository.",
            package_files,
        )

    if theme_files and not reduced_motion_files:
        add(
            "DS012",
            "Theme support detected without reduced-motion handling",
            "medium",
            "medium",
            "accessibility",
            f"Theme or color-scheme selectors appear in {len(set(theme_files))} files, but no prefers-reduced-motion query was detected.",
            "Define reduced-motion tokens and component behavior. Verify whether motion is implemented in another package before classifying as confirmed.",
            theme_files,
        )

    if theme_files and not forced_colors_files:
        add(
            "DS013",
            "Theme support detected without forced-colors handling",
            "medium",
            "medium",
            "accessibility",
            f"Theme or color-scheme selectors appear in {len(set(theme_files))} files, but no forced-colors handling was detected.",
            "Test representative components in forced-colors mode and add targeted semantic fallbacks without disabling system colors globally.",
            theme_files,
        )

    if bootstrap_files and css_vars:
        bs_vars = [name for name in css_vars if name.startswith("--bs-")]
        non_bs = [name for name in css_vars if not name.startswith("--bs-")]
        if bs_vars and non_bs:
            add(
                "DS014",
                "Bootstrap variables and custom token variables coexist",
                "low",
                "medium",
                "integration",
                f"Detected {len(bs_vars)} Bootstrap custom properties and {len(non_bs)} non-Bootstrap custom properties.",
                "Document the adapter direction and ownership. Map semantic tokens to supported Bootstrap variables instead of allowing two uncontrolled theme systems.",
                bootstrap_files,
            )

    if important_files and len(important_files) >= 5:
        add(
            "DS015",
            "Widespread !important usage may make component and theme contracts fragile",
            "medium",
            "medium",
            "architecture",
            f"!important appears in {len(set(important_files))} style files.",
            "Classify utilities, vendor overrides, and emergency patches. Define cascade order, specificity, and supported override boundaries before removing declarations.",
            important_files,
        )

    if len(z_indexes) >= 6:
        values = ", ".join(sorted(z_indexes, key=lambda item: int(item))[:15])
        add(
            "DS016",
            "Many independent z-index values suggest an unmanaged elevation scale",
            "medium",
            "medium",
            "tokens",
            f"Detected {len(z_indexes)} distinct numeric z-index values: {values}.",
            "Define semantic stacking roles such as base, sticky, dropdown, modal, toast, and emergency overlay. Verify stacking contexts before replacing values.",
            [path for paths in z_indexes.values() for path in paths],
        )

    if icon_font_files:
        add(
            "DS017",
            "Icon-font usage requires explicit accessibility and migration policy",
            "low",
            "medium",
            "assets",
            f"Potential icon-font definitions detected in {len(set(icon_font_files))} files.",
            "Ensure decorative glyphs are hidden, meaningful icons have accessible names, and document whether SVG or another asset path will replace the icon font.",
            icon_font_files,
        )

    if invalid_json:
        add(
            "DS018",
            "JSON files could not be parsed during token inspection",
            "low",
            "high",
            "tooling",
            "; ".join(invalid_json[:10]),
            "Confirm whether these files are JSON5 or generated content. Keep the canonical token source machine-valid and validate it in CI.",
            [entry.split(":", 1)[0] for entry in invalid_json],
        )

    if component_candidates and not package_files:
        add(
            "DS019",
            "Component candidates are not associated with detected package metadata",
            "low",
            "low",
            "distribution",
            f"Found {len(component_candidates)} component candidates and no package.json file in the scanned scope.",
            "Confirm whether the system is distributed as static assets, server templates, or from a parent repository. Document versioning and consumer update paths.",
            component_candidates[:20],
        )

    summary = {
        "root": str(root),
        "files_scanned": len(records),
        "token_files": len(token_files),
        "tokens": len(all_tokens),
        "token_types": dict(token_types),
        "deprecated_tokens": len(deprecated_tokens),
        "css_custom_properties": len(css_vars),
        "sass_variables": len(sass_vars),
        "less_variables": len(less_vars),
        "component_candidates": len(component_candidates),
        "stories": len(stories),
        "tests": len(tests),
        "documentation_files": len(docs),
        "package_files": len(package_files),
        "changelog_or_changeset_files": len(changelogs),
        "theme_files": len(set(theme_files)),
        "tools": sorted(tools),
        "finding_counts": dict(collections.Counter(f.severity for f in findings)),
    }

    inventory = {
        "token_files": {name: len(tokens) for name, tokens in token_files.items()},
        "package_versions": package_versions,
        "common_css_prefixes": css_prefixes.most_common(12),
        "common_class_prefixes": class_prefixes.most_common(12),
        "top_css_custom_properties": css_vars.most_common(25),
        "top_sass_variables": sass_vars.most_common(25),
        "top_less_variables": less_vars.most_common(25),
        "component_candidates": component_candidates[:100],
        "stories": sorted(stories)[:100],
        "tests": sorted(tests)[:100],
        "theme_files": sorted(set(theme_files))[:100],
    }

    findings.sort(key=lambda f: (-SEVERITY_RANK[f.severity], f.rule_id))
    return {"summary": summary, "inventory": inventory, "findings": [asdict(f) for f in findings]}


def render_markdown(report: dict[str, Any]) -> str:
    summary = report["summary"]
    inventory = report["inventory"]
    findings = report["findings"]
    lines: list[str] = []
    lines.append("# Design System Static Inspection")
    lines.append("")
    lines.append("> Static repository evidence only. Confirm runtime behavior, accessibility, visual output, public API, and actual consumer usage separately.")
    lines.append("")
    lines.append("## Summary")
    lines.append("")
    lines.append("| Metric | Value |")
    lines.append("| --- | ---: |")
    for key in (
        "files_scanned",
        "token_files",
        "tokens",
        "css_custom_properties",
        "sass_variables",
        "less_variables",
        "component_candidates",
        "stories",
        "tests",
        "documentation_files",
        "package_files",
        "changelog_or_changeset_files",
        "theme_files",
    ):
        lines.append(f"| {key.replace('_', ' ')} | {summary[key]} |")
    lines.append("")
    if summary["tools"]:
        lines.append("Detected tooling: " + ", ".join(f"`{item}`" for item in summary["tools"]))
        lines.append("")

    lines.append("## Findings")
    lines.append("")
    if not findings:
        lines.append("No findings were produced by the static heuristics.")
        lines.append("")
    for finding in findings:
        lines.append(f"### {finding['rule_id']} - {finding['title']}")
        lines.append("")
        lines.append(f"- **Severity:** {finding['severity'].title()}")
        lines.append(f"- **Confidence:** {finding['confidence'].title()}")
        lines.append(f"- **Category:** {finding['category']}")
        lines.append(f"- **Evidence:** {finding['evidence']}")
        lines.append(f"- **Remediation:** {finding['remediation']}")
        if finding["files"]:
            lines.append("- **Files:** " + ", ".join(f"`{item}`" for item in finding["files"][:15]))
        lines.append("")

    lines.append("## Inventory")
    lines.append("")
    if inventory["token_files"]:
        lines.append("### Token files")
        lines.append("")
        for name, count in sorted(inventory["token_files"].items()):
            lines.append(f"- `{name}`: {count} token(s)")
        lines.append("")
    if inventory["package_versions"]:
        lines.append("### Packages")
        lines.append("")
        for package in inventory["package_versions"]:
            label = package["name"] or "unnamed"
            version = package["version"] or "no version"
            lines.append(f"- `{package['file']}`: {label} {version}")
        lines.append("")
    if inventory["common_css_prefixes"]:
        lines.append("### Common CSS custom-property prefixes")
        lines.append("")
        lines.append(", ".join(f"`{name}` ({count})" for name, count in inventory["common_css_prefixes"]))
        lines.append("")
    if inventory["component_candidates"]:
        lines.append("### Component candidates")
        lines.append("")
        for name in inventory["component_candidates"][:30]:
            lines.append(f"- `{name}`")
        lines.append("")

    return "\n".join(lines).rstrip() + "\n"


def write_output(content: str, output: str | None) -> None:
    if output:
        Path(output).write_text(content, encoding="utf-8")
    else:
        sys.stdout.write(content)


def main() -> int:
    args = parse_args()
    root = Path(args.path).resolve()
    if not root.exists() or not root.is_dir():
        print(f"error: project directory not found: {root}", file=sys.stderr)
        return 1
    records = read_records(root, args.max_files)
    report = inspect(root, records)
    if args.format == "json":
        content = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
    else:
        content = render_markdown(report)
    write_output(content, args.output)

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


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