#!/usr/bin/env python3
"""Inventory React/Next.js UI without modifying the source repository."""

from __future__ import annotations

import argparse
import json
import re
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

EXCLUDED_DIRS = {
    ".git", ".next", ".nuxt", ".output", ".turbo", ".vercel",
    "build", "coverage", "dist", "node_modules", "out", "storybook-static",
}
SCRIPT_EXTENSIONS = {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"}
STYLE_EXTENSIONS = {".css", ".scss", ".sass", ".less", ".pcss"}
SOURCE_EXTENSIONS = SCRIPT_EXTENSIONS | STYLE_EXTENSIONS

IMPORT_RE = re.compile(r"(?:from\s+|import\s*\(|require\s*\()\s*['\"]([^'\"]+)['\"]")
EXPORT_COMPONENT_RE = re.compile(
    r"(?:export\s+(?:default\s+)?)?(?:async\s+)?(?:function|class)\s+([A-Z][A-Za-z0-9_]*)"
    r"|(?:export\s+)?const\s+([A-Z][A-Za-z0-9_]*)\s*(?::[^=]+)?=\s*(?:React\.)?(?:forwardRef|memo|lazy|\()"
)
JSX_TAG_RE = re.compile(r"<([A-Z][A-Za-z0-9_]*)(?:\.|\s|/|>)")
CLASS_RE = re.compile(
    r"className\s*=\s*(?:['\"]([^'\"]+)['\"]|`([^`]+)`|\{\s*['\"]([^'\"]+)['\"]\s*\})",
    re.DOTALL,
)
HEX_RE = re.compile(r"(?<![\w-])#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})(?![\w-])")
COLOR_FN_RE = re.compile(r"\b(?:rgb|rgba|hsl|hsla|oklch|oklab|color)\([^\n;{}]+\)", re.IGNORECASE)
PX_RADIUS_RE = re.compile(r"border-radius\s*:\s*([^;\n}]+)", re.IGNORECASE)
BOX_SHADOW_RE = re.compile(r"box-shadow\s*:\s*([^;\n}]+)", re.IGNORECASE)
Z_INDEX_RE = re.compile(r"z-index\s*:\s*([^;\n}]+)", re.IGNORECASE)
TRANSITION_RE = re.compile(r"(?:transition|animation-duration)\s*:\s*([^;\n}]+)", re.IGNORECASE)
THEME_SELECTOR_RE = re.compile(r"(?:\[data-theme\s*=\s*['\"]?([^'\"\]]+)['\"]?\]|\.dark\b|:root\b)")
VARIANT_RE = re.compile(r"(?:variant|tone|intent)\s*:\s*\{([^{}]+)\}", re.DOTALL)
SIZE_RE = re.compile(r"size\s*:\s*\{([^{}]+)\}", re.DOTALL)
OBJECT_KEY_RE = re.compile(r"(?:^|[,\n])\s*['\"]?([a-zA-Z][\w-]*)['\"]?\s*:")

UPSTREAM_PREFIXES = (
    "@radix-ui/", "radix-ui", "motion", "framer-motion", "react-aria",
    "@react-aria/", "@floating-ui/", "@headlessui/", "@ark-ui/",
    "@base-ui-components/",
)


@dataclass(frozen=True)
class SourceFile:
    path: Path
    relative: str
    text: str


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--root", type=Path, required=True, help="Application repository root")
    parser.add_argument("--json", dest="json_path", type=Path, required=True)
    parser.add_argument("--markdown", dest="markdown_path", type=Path, required=True)
    return parser.parse_args()


def iter_source_paths(root: Path) -> Iterable[Path]:
    for path in root.rglob("*"):
        if not path.is_file() or path.suffix.lower() not in SOURCE_EXTENSIONS:
            continue
        if any(part in EXCLUDED_DIRS for part in path.relative_to(root).parts):
            continue
        yield path


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


def load_package_json(root: Path) -> dict[str, Any]:
    path = root / "package.json"
    if not path.exists():
        return {}
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}
    return value if isinstance(value, dict) else {}


def dependency_version(package: dict[str, Any], name: str) -> str | None:
    for field in ("dependencies", "devDependencies", "peerDependencies"):
        values = package.get(field)
        if isinstance(values, dict) and isinstance(values.get(name), str):
            return values[name]
    return None


def detect_package_manager(root: Path, package: dict[str, Any]) -> str:
    declared = package.get("packageManager")
    if isinstance(declared, str) and declared:
        return declared.split("@", 1)[0]
    for filename, manager in (
        ("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
        ("yarn.lock", "yarn"), ("package-lock.json", "npm"),
    ):
        if (root / filename).exists():
            return manager
    return "unknown"


def classify_component(name: str, relative: str) -> str:
    lower = f"{relative}/{name}".lower()
    if any(token in lower for token in ("layout", "template", "shell")):
        return "layout"
    if any(token in lower for token in ("prose", "markdown", "mdx", "gutenberg", "rich-text", "richtext")):
        return "content-adapter"
    if any(token in lower for token in ("feature", "route", "page", "screen")):
        return "application"
    primitive_names = {
        "accordion", "alert", "avatar", "badge", "box", "button", "card", "checkbox",
        "container", "dialog", "drawer", "grid", "heading", "input", "label", "link",
        "menu", "popover", "radio", "section", "select", "separator", "slider", "stack",
        "surface", "switch", "tabs", "text", "textarea", "toast", "toggle", "tooltip",
    }
    if any(token in name.lower() for token in primitive_names):
        return "primitive"
    if any(token in lower for token in ("components/ui", "primitives", "/ui/")):
        return "primitive"
    if any(token in lower for token in ("pattern", "blocks", "shared")):
        return "pattern"
    return "unknown"


def normalize_classes(value: str) -> str | None:
    if "${" in value or "{" in value or "}" in value:
        return None
    tokens = [token for token in re.split(r"\s+", value.strip()) if token]
    return " ".join(sorted(tokens)) if tokens else None


def extract_object_keys(match: re.Match[str] | None) -> list[str]:
    return [] if match is None else sorted(set(OBJECT_KEY_RE.findall(match.group(1))))


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


def value_occurrences(source: SourceFile) -> list[dict[str, Any]]:
    patterns = (
        ("color", HEX_RE), ("color-function", COLOR_FN_RE), ("radius", PX_RADIUS_RE),
        ("shadow", BOX_SHADOW_RE), ("z-index", Z_INDEX_RE), ("motion", TRANSITION_RE),
    )
    values: list[dict[str, Any]] = []
    for kind, pattern in patterns:
        for match in pattern.finditer(source.text):
            values.append({
                "kind": kind,
                "value": match.group(0).strip(),
                "path": source.relative,
                "line": line_number(source.text, match.start()),
            })
    return values


def build_inventory(root: Path) -> dict[str, Any]:
    root = root.resolve()
    package = load_package_json(root)
    sources = [
        SourceFile(path=path, relative=path.relative_to(root).as_posix(), text=read_text(path))
        for path in sorted(iter_source_paths(root))
    ]

    tag_counts: Counter[str] = Counter()
    tag_files: dict[str, set[str]] = defaultdict(set)
    imports_by_path: dict[str, list[str]] = {}
    dependency_counts: Counter[str] = Counter()
    class_locations: dict[str, list[dict[str, Any]]] = defaultdict(list)
    hard_values_by_path: dict[str, list[dict[str, Any]]] = defaultdict(list)
    hard_coded_values: list[dict[str, Any]] = []
    themes: set[str] = set()

    # First pass: gather repository-wide evidence before computing component usage.
    for source in sources:
        imports = sorted(set(IMPORT_RE.findall(source.text)))
        imports_by_path[source.relative] = imports
        dependency_counts.update(imports)

        for tag in JSX_TAG_RE.findall(source.text):
            tag_counts[tag] += 1
            tag_files[tag].add(source.relative)

        for match in CLASS_RE.finditer(source.text):
            raw = next((group for group in match.groups() if group is not None), "")
            signature = normalize_classes(raw)
            if signature:
                class_locations[signature].append({
                    "path": source.relative,
                    "line": line_number(source.text, match.start()),
                    "raw": " ".join(raw.split()),
                })

        values = value_occurrences(source)
        hard_values_by_path[source.relative].extend(values)
        hard_coded_values.extend(values)

        if source.path.suffix.lower() in STYLE_EXTENSIONS:
            for match in THEME_SELECTOR_RE.finditer(source.text):
                selector = match.group(0)
                if selector == ":root":
                    themes.add("root")
                elif selector == ".dark":
                    themes.add("dark")
                else:
                    themes.add(match.group(1) or selector)

    # Second pass: component records now see complete usage counts and locations.
    components: list[dict[str, Any]] = []
    for source in sources:
        if source.path.suffix.lower() not in SCRIPT_EXTENSIONS:
            continue
        names = {
            match.group(1) or match.group(2)
            for match in EXPORT_COMPONENT_RE.finditer(source.text)
            if match.group(1) or match.group(2)
        }
        imports = imports_by_path[source.relative]
        upstream = sorted(item for item in imports if item.startswith(UPSTREAM_PREFIXES))
        variants = extract_object_keys(VARIANT_RE.search(source.text))
        sizes = extract_object_keys(SIZE_RE.search(source.text))

        for name in sorted(names):
            components.append({
                "name": name,
                "path": source.relative,
                "kind": classify_component(name, source.relative),
                "exports": [name],
                "imports": imports,
                "upstream_imports": upstream,
                "style_sources": [
                    source.path.suffix.lower().lstrip("."),
                    *(["Tailwind"] if "className" in source.text else []),
                ],
                "usage_count": tag_counts[name],
                "usage_files": sorted(tag_files[name]),
                "variants": variants,
                "sizes": sizes,
                "states": sorted(
                    state for state in (
                        "disabled", "loading", "invalid", "selected", "open", "closed",
                        "hover", "focus-visible", "active", "checked",
                    ) if state in source.text
                ),
                "breakpoints": sorted(
                    breakpoint for breakpoint in ("sm:", "md:", "lg:", "xl:", "2xl:")
                    if breakpoint in source.text
                ),
                "application_dependencies": sorted(
                    item for item in imports
                    if any(token in item.lower() for token in ("/api", "/db", "/server", "/auth", "/queries"))
                ),
                "hard_coded_values": hard_values_by_path[source.relative],
                "candidate_target": None,
                "candidate_basis": "existing",
                "notes": [],
            })

    duplicate_classes = [
        {"signature": signature, "count": len(locations), "locations": locations, "candidate": "unknown"}
        for signature, locations in sorted(class_locations.items()) if len(locations) > 1
    ]
    style_entries = sorted(
        source.relative for source in sources
        if source.path.suffix.lower() in STYLE_EXTENSIONS
        and any(token in source.path.name.lower() for token in ("global", "theme", "tailwind", "app"))
    )
    layouts = sorted({
        source.relative for source in sources
        if source.path.suffix.lower() in SCRIPT_EXTENSIONS
        and any(token in source.relative.lower() for token in ("layout", "template", "shell"))
    })
    dependencies = [
        {"name": name, "version": dependency_version(package, name), "import_occurrences": dependency_counts[name]}
        for name in sorted(dependency_counts)
        if name.startswith(UPSTREAM_PREFIXES) or name in {"tailwindcss", "next", "react"}
    ]
    kind_counts = Counter(item["kind"] for item in components)

    return {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "root": str(root),
        "framework": {
            "name": "Next.js" if dependency_version(package, "next") else "React or unknown",
            "version": dependency_version(package, "next"),
            "react_version": dependency_version(package, "react"),
            "typescript": any(source.path.suffix.lower() in {".ts", ".tsx"} for source in sources),
            "tailwind_version": dependency_version(package, "tailwindcss"),
            "router": "app" if (root / "app").exists() or (root / "src" / "app").exists() else "unknown",
            "package_manager": detect_package_manager(root, package),
        },
        "style_entries": style_entries,
        "dependencies": dependencies,
        "components": sorted(components, key=lambda item: (item["kind"], item["name"], item["path"])),
        "layouts": layouts,
        "themes": [{"id": item} for item in sorted(themes)],
        "duplicate_class_signatures": duplicate_classes,
        "hard_coded_design_values": hard_coded_values,
        "summary": {
            "component_files": len({item["path"] for item in components}),
            "tsx_files": sum(source.path.suffix.lower() == ".tsx" for source in sources),
            "css_files": sum(source.path.suffix.lower() in STYLE_EXTENSIONS for source in sources),
            "radix_imports": sum(count for name, count in dependency_counts.items() if name.startswith(("@radix-ui/", "radix-ui"))),
            "motion_imports": sum(count for name, count in dependency_counts.items() if name.startswith(("motion", "framer-motion"))),
            "tailwind_class_occurrences": sum(len(locations) for locations in class_locations.values()),
            "duplicate_class_groups": len(duplicate_classes),
            "hard_coded_color_occurrences": sum(item["kind"] in {"color", "color-function"} for item in hard_coded_values),
            "candidate_primitives": kind_counts["primitive"],
            "candidate_patterns": kind_counts["pattern"],
            "candidate_layouts": kind_counts["layout"],
            "application_owned": kind_counts["application"],
            "unknown": kind_counts["unknown"],
        },
    }


def markdown_report(inventory: dict[str, Any]) -> str:
    framework = inventory["framework"]
    lines = [
        "# UI inventory", "",
        f"Generated: `{inventory['generated_at']}`",
        f"Root: `{inventory['root']}`", "",
        "## Framework", "",
        f"- Framework: {framework['name']} {framework['version'] or ''}".rstrip(),
        f"- React: {framework['react_version'] or 'unknown'}",
        f"- Tailwind: {framework['tailwind_version'] or 'unknown'}",
        f"- Package manager: {framework['package_manager']}", "",
        "## Summary", "",
    ]
    for key, value in inventory["summary"].items():
        lines.append(f"- {key.replace('_', ' ').title()}: {value}")

    lines.extend([
        "", "## Components", "",
        "| Name | Kind | Path | Uses | Variants | Upstream |",
        "| --- | --- | --- | ---: | --- | --- |",
    ])
    for item in inventory["components"]:
        lines.append(
            "| {name} | {kind} | `{path}` | {uses} | {variants} | {upstream} |".format(
                name=item["name"], kind=item["kind"], path=item["path"],
                uses=item["usage_count"], variants=", ".join(item["variants"]) or "—",
                upstream=", ".join(item["upstream_imports"]) or "—",
            )
        )

    lines.extend([
        "", "## Duplicate class signatures", "",
        "| Count | Signature | First location |",
        "| ---: | --- | --- |",
    ])
    duplicates = sorted(
        inventory["duplicate_class_signatures"],
        key=lambda item: (-item["count"], item["signature"]),
    )[:100]
    for item in duplicates:
        location = item["locations"][0]
        signature = item["signature"].replace("|", "\\|")
        lines.append(f"| {item['count']} | `{signature}` | `{location['path']}:{location['line']}` |")

    lines.extend([
        "", "## Next review steps", "",
        "1. Complete classifications and usage evidence manually.",
        "2. Capture visual and behavioral baselines.",
        "3. Create the extraction map from `references/audit-schema.md`.",
        "4. Select an existing or official basis for every candidate.",
        "5. Migrate one dependency-ordered component family at a time.", "",
    ])
    return "\n".join(lines)


def main() -> int:
    args = parse_args()
    root = args.root.resolve()
    if not root.is_dir():
        raise SystemExit(f"Root is not a directory: {root}")

    inventory = build_inventory(root)
    args.json_path.parent.mkdir(parents=True, exist_ok=True)
    args.markdown_path.parent.mkdir(parents=True, exist_ok=True)
    args.json_path.write_text(json.dumps(inventory, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    args.markdown_path.write_text(markdown_report(inventory), encoding="utf-8")
    print(f"Wrote {args.json_path}")
    print(f"Wrote {args.markdown_path}")
    return 0


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