#!/usr/bin/env python3
"""Inspect a source tree for Bootstrap versions, integration style, and migration hazards.

The report is evidence-based and intentionally conservative. It does not replace
manual inspection of rendered pages, lock files, or runtime plugin versions.
"""

from __future__ import annotations

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

TEXT_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".php", ".phtml", ".twig", ".blade.php",
    ".cfm", ".cfml", ".cfc", ".cshtml", ".vbhtml", ".aspx", ".ascx",
    ".master", ".jsp", ".jspx", ".tag", ".vue", ".svelte", ".jsx", ".tsx",
    ".js", ".mjs", ".cjs", ".ts", ".css", ".scss", ".sass", ".less",
    ".json", ".xml", ".yaml", ".yml", ".lock", ".md", ".txt", ".config",
    ".csproj", ".fsproj", ".vbproj", ".props", ".targets",
}

IMPORTANT_FILENAMES = {
    "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
    "bower.json", "composer.json", "composer.lock", "packages.config",
    "libman.json", "Gemfile", "Gemfile.lock", "mix-manifest.json",
}

DEFAULT_IGNORES = {
    ".git", ".hg", ".svn", ".idea", ".vscode", "node_modules", "vendor",
    "dist", "build", "coverage", ".next", ".nuxt", ".cache", "bin", "obj",
    "target", "packages",
}

VERSION_PATTERNS = [
    re.compile(r"Bootstrap\s+v?(\d+\.\d+(?:\.\d+)?)", re.I),
    re.compile(r"bootstrap(?:@|/|-)(\d+\.\d+(?:\.\d+)?)", re.I),
    re.compile(r'"bootstrap"\s*:\s*"[^"\d]*(\d+\.\d+(?:\.\d+)?)', re.I),
    re.compile(r"bootstrap@(?:\^|~|>=|<=|>|<|=|\s)*(\d+\.\d+(?:\.\d+)?)", re.I),
    re.compile(r"twbs/bootstrap[^\d]*(\d+\.\d+(?:\.\d+)?)", re.I),
]

FINGERPRINTS = {
    "2": [
        (re.compile(r"\brow-fluid\b"), "row-fluid"),
        (re.compile(r"\bspan(?:1[0-2]|[1-9])\b"), "span grid"),
        (re.compile(r"\bhero-unit\b"), "hero-unit"),
        (re.compile(r"\bnavbar-inner\b"), "navbar-inner"),
        (re.compile(r"\bbtn-navbar\b"), "btn-navbar"),
    ],
    "3": [
        (re.compile(r"\bcol-xs-(?:auto|\d+)\b"), "col-xs grid"),
        (re.compile(r"\bpanel-(?:default|primary|success|info|warning|danger|heading|body|footer|title)\b"), "panels"),
        (re.compile(r"\bglyphicon(?:-[\w-]+)?\b"), "Glyphicons"),
        (re.compile(r"\bnavbar-toggle\b"), "navbar-toggle"),
        (re.compile(r"\binput-group-addon\b"), "input-group-addon"),
        (re.compile(r"\bvisible-(?:xs|sm|md|lg)\b|\bhidden-(?:xs|sm|md|lg)\b"), "legacy responsive utilities"),
    ],
    "4": [
        (re.compile(r"\bcustom-(?:control|checkbox|radio|select|file|range|switch)\b"), "custom forms"),
        (re.compile(r"\binput-group-(?:prepend|append)\b"), "input group wrappers"),
        (re.compile(r"\bcard-(?:deck|columns)\b"), "card layout"),
        (re.compile(r"\bform-row\b"), "form-row"),
        (re.compile(r"\bbadge-pill\b"), "badge-pill"),
        (re.compile(r"\bno-gutters\b"), "no-gutters"),
    ],
    "5": [
        (re.compile(r"\bdata-bs-(?:toggle|target|dismiss|parent|ride|spy|theme)\b"), "data-bs attributes"),
        (re.compile(r"\bvisually-hidden(?:-focusable)?\b"), "visually-hidden"),
        (re.compile(r"\b(?:m|p)[se]-(?:auto|[0-5])\b"), "logical spacing utilities"),
        (re.compile(r"\b(?:text|float)-(?:start|end)\b"), "logical direction utilities"),
        (re.compile(r"\boffcanvas(?:-[\w-]+)?\b"), "offcanvas"),
        (re.compile(r"\b(?:bg|border|text)-[\w-]+-(?:subtle|emphasis)\b"), "5.3 color utilities"),
    ],
}

HAZARDS = {
    "legacy-data-api": (
        re.compile(r"\bdata-(?:toggle|target|dismiss|parent|ride|spy|offset)\s*="),
        "Bootstrap 3/4 data attributes require component-by-component migration to data-bs-* in Bootstrap 5.",
    ),
    "jquery-plugin-api": (
        re.compile(r"\$\([^\n;]+\)\.(?:modal|collapse|dropdown|tab|tooltip|popover|toast|carousel|button|alert)\s*\("),
        "jQuery Bootstrap plugin calls must be rewritten for Bootstrap 5; inventory other jQuery consumers before removing jQuery.",
    ),
    "sr-only": (
        re.compile(r"\bsr-only(?:-focusable)?\b"),
        "Bootstrap 5 renamed visually hidden helpers; focusable helper semantics must be reviewed.",
    ),
    "directional-utilities": (
        re.compile(r"\b(?:m|p)[lr]-(?:auto|[0-5])\b|\b(?:text|float)-(?:left|right)\b|\bdropdown-menu-(?:left|right)\b"),
        "Bootstrap 5 uses logical start/end utilities; test LTR and RTL rather than replacing blindly.",
    ),
    "custom-forms": (
        re.compile(r"\bcustom-(?:control|checkbox|radio|select|file|range|switch)(?:-[\w-]+)?\b"),
        "Bootstrap 4 custom form markup is redesigned in Bootstrap 5 and requires semantic markup review.",
    ),
    "input-group-wrappers": (
        re.compile(r"\binput-group-(?:prepend|append)\b"),
        "Bootstrap 5 removes prepend/append wrappers; rewrite the input-group structure.",
    ),
    "removed-v5-components": (
        re.compile(r"\b(?:jumbotron|media-body|card-deck|card-columns|embed-responsive(?:-item)?|no-gutters|badge-pill)\b"),
        "The matched Bootstrap 4 pattern is removed or replaced in Bootstrap 5.",
    ),
    "glyphicons": (
        re.compile(r"\bglyphicon(?:-[\w-]+)?\b"),
        "Glyphicons are removed after Bootstrap 3; replace with an explicit accessible icon strategy.",
    ),
    "bootstrap-2-grid": (
        re.compile(r"\brow-fluid\b|\bspan(?:1[0-2]|[1-9])\b"),
        "Bootstrap 2 grid markup requires layout redesign for later mobile-first grids.",
    ),
    "core-file-overrides": (
        re.compile(r"(?:^|[/{])bootstrap(?:\.min)?\.(?:css|js)$", re.I),
        "A project-owned file named like a Bootstrap core asset may be copied or modified vendor code; verify provenance.",
    ),
}

BUILD_PATTERNS = {
    "less": re.compile(r"bootstrap(?:/|\\)less|bootstrap\.less|@import[^;]+bootstrap", re.I),
    "sass": re.compile(r"bootstrap(?:/|\\)scss|@use\s+['\"]bootstrap|@import[^;]+bootstrap/scss", re.I),
    "jquery": re.compile(r"\bjquery\b|\$\(", re.I),
    "popper-v1": re.compile(r"\bpopper\.js\b|\bPopper\s*\(", re.I),
    "popper-v2": re.compile(r"@popperjs/core|createPopper\s*\(", re.I),
}


@dataclass
class Evidence:
    kind: str
    value: str
    file: str
    line: int | None = None


@dataclass
class Finding:
    code: str
    description: str
    count: int
    samples: list[str]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("path", type=Path, help="Project directory or file to inspect")
    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("--max-file-size", type=int, default=2_000_000, help="Maximum bytes read per file")
    parser.add_argument("--include-vendor", action="store_true", help="Include vendor/build directories")
    parser.add_argument("--max-samples", type=int, default=8, help="Maximum sample locations per finding")
    return parser.parse_args()


def is_text_candidate(path: Path) -> bool:
    if path.name in IMPORTANT_FILENAMES:
        return True
    if path.name.endswith(".blade.php"):
        return True
    return path.suffix.lower() in TEXT_EXTENSIONS


def iter_files(root: Path, include_vendor: bool) -> Iterable[Path]:
    if root.is_file():
        yield root
        return

    ignores = set() if include_vendor else DEFAULT_IGNORES
    for current_root, dirs, files in os.walk(root):
        dirs[:] = [name for name in dirs if name not in ignores]
        base = Path(current_root)
        for name in files:
            path = base / name
            if is_text_candidate(path):
                yield path


def read_text(path: Path, max_size: int) -> str | None:
    try:
        if path.stat().st_size > max_size:
            return None
        return path.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return None


def rel(path: Path, root: Path) -> str:
    try:
        return str(path.relative_to(root if root.is_dir() else root.parent))
    except ValueError:
        return str(path)


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


def collect_report(args: argparse.Namespace) -> dict:
    root = args.path.resolve()
    if not root.exists():
        raise FileNotFoundError(f"Path does not exist: {root}")

    evidence: list[Evidence] = []
    scores: Counter[str] = Counter()
    fingerprints: dict[str, Counter[str]] = {major: Counter() for major in FINGERPRINTS}
    hazard_counts: Counter[str] = Counter()
    hazard_samples: dict[str, list[str]] = defaultdict(list)
    build_counts: Counter[str] = Counter()
    scanned = 0
    skipped_large = 0
    extensions: Counter[str] = Counter()

    for path in iter_files(root, args.include_vendor):
        text = read_text(path, args.max_file_size)
        if text is None:
            skipped_large += 1
            continue
        scanned += 1
        extensions[path.suffix.lower() or path.name] += 1
        location = rel(path, root)

        for pattern in VERSION_PATTERNS:
            for match in pattern.finditer(text):
                version = match.group(1)
                major = version.split(".", 1)[0]
                evidence.append(Evidence("version", version, location, line_number(text, match.start())))
                scores[major] += 8

        for major, patterns in FINGERPRINTS.items():
            for pattern, label in patterns:
                matches = list(pattern.finditer(text))
                if matches:
                    fingerprints[major][label] += len(matches)
                    scores[major] += min(len(matches), 10)

        for code, (pattern, _description) in HAZARDS.items():
            if code == "core-file-overrides":
                if pattern.search(str(path).replace("\\", "/")):
                    hazard_counts[code] += 1
                    if len(hazard_samples[code]) < args.max_samples:
                        hazard_samples[code].append(location)
                continue
            matches = list(pattern.finditer(text))
            if not matches:
                continue
            hazard_counts[code] += len(matches)
            for match in matches:
                if len(hazard_samples[code]) >= args.max_samples:
                    break
                hazard_samples[code].append(f"{location}:{line_number(text, match.start())}")

        for name, pattern in BUILD_PATTERNS.items():
            count = len(pattern.findall(text))
            if count:
                build_counts[name] += count

    dedup_evidence: list[Evidence] = []
    seen = set()
    for item in evidence:
        key = (item.kind, item.value, item.file, item.line)
        if key not in seen:
            seen.add(key)
            dedup_evidence.append(item)

    ranked = scores.most_common()
    detected = "unknown"
    confidence = "low"
    mixed = False
    if ranked:
        detected = ranked[0][0]
        if len(ranked) > 1 and ranked[1][1] >= max(5, ranked[0][1] * 0.55):
            mixed = True
        exact_majors = {item.value.split(".", 1)[0] for item in dedup_evidence}
        if len(exact_majors) == 1:
            confidence = "high"
        elif ranked[0][1] >= 12:
            confidence = "medium"
        if len(exact_majors) > 1:
            mixed = True

    exact_versions = sorted({item.value for item in dedup_evidence}, key=version_key)
    findings = [
        Finding(code, HAZARDS[code][1], count, hazard_samples[code])
        for code, count in hazard_counts.most_common()
    ]

    return {
        "path": str(root),
        "scanned_files": scanned,
        "skipped_large_or_unreadable": skipped_large,
        "detected_major": detected,
        "confidence": confidence,
        "mixed_version_evidence": mixed,
        "exact_versions_found": exact_versions,
        "major_scores": dict(ranked),
        "version_evidence": [asdict(item) for item in dedup_evidence[:40]],
        "fingerprints": {major: dict(values) for major, values in fingerprints.items() if values},
        "integration_signals": dict(build_counts.most_common()),
        "migration_findings": [asdict(item) for item in findings],
        "file_types": dict(extensions.most_common(20)),
        "limitations": [
            "Class fingerprints cannot identify an exact patch version.",
            "Generated templates, runtime DOM, dynamically composed class names, and excluded vendor directories may be missed.",
            "CSS and JavaScript versions must be verified independently in the running application.",
            "Findings indicate review candidates, not automatic replacements.",
        ],
    }


def version_key(value: str) -> tuple[int, ...]:
    return tuple(int(part) for part in value.split("."))


def render_markdown(report: dict) -> str:
    lines = [
        "# Bootstrap inspection",
        "",
        f"- Path: `{report['path']}`",
        f"- Files scanned: {report['scanned_files']}",
        f"- Detected major: **{report['detected_major']}**",
        f"- Confidence: **{report['confidence']}**",
        f"- Mixed-version evidence: **{'yes' if report['mixed_version_evidence'] else 'no'}**",
    ]
    if report["exact_versions_found"]:
        lines.append(f"- Exact versions found: {', '.join(report['exact_versions_found'])}")

    lines.extend(["", "## Major scores", "", "| Major | Score |", "|---:|---:|"])
    if report["major_scores"]:
        for major, score in report["major_scores"].items():
            lines.append(f"| {major} | {score} |")
    else:
        lines.append("| unknown | 0 |")

    lines.extend(["", "## Version evidence", "", "| Version | File | Line |", "|---|---|---:|"])
    if report["version_evidence"]:
        for item in report["version_evidence"]:
            lines.append(f"| {item['value']} | `{item['file']}` | {item['line'] or ''} |")
    else:
        lines.append("| none |  |  |")

    lines.extend(["", "## Integration signals", ""])
    if report["integration_signals"]:
        for name, count in report["integration_signals"].items():
            lines.append(f"- {name}: {count}")
    else:
        lines.append("- No strong Less/Sass/jQuery/Popper signals detected.")

    lines.extend(["", "## Migration findings", ""])
    if report["migration_findings"]:
        for finding in report["migration_findings"]:
            lines.append(f"### {finding['code']} ({finding['count']})")
            lines.append("")
            lines.append(finding["description"])
            if finding["samples"]:
                lines.append("")
                for sample in finding["samples"]:
                    lines.append(f"- `{sample}`")
            lines.append("")
    else:
        lines.append("No configured migration patterns were found.")

    lines.extend(["", "## Limitations", ""])
    for item in report["limitations"]:
        lines.append(f"- {item}")
    return "\n".join(lines).rstrip() + "\n"


def main() -> int:
    args = parse_args()
    try:
        report = collect_report(args)
    except (FileNotFoundError, PermissionError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    output = json.dumps(report, indent=2, ensure_ascii=False) + "\n" if args.format == "json" else render_markdown(report)
    if args.output:
        args.output.parent.mkdir(parents=True, exist_ok=True)
        args.output.write_text(output, encoding="utf-8")
    else:
        sys.stdout.write(output)
    return 0


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