#!/usr/bin/env python3
"""Inspect source files for font usage, delivery, and migration risks."""

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 = {
    ".css", ".scss", ".sass", ".less", ".html", ".htm", ".php", ".cfm",
    ".cshtml", ".razor", ".vue", ".svelte", ".jsx", ".tsx", ".js", ".ts",
    ".json", ".xml", ".svg", ".md", ".yml", ".yaml",
}
FONT_EXTENSIONS = {".woff2", ".woff", ".ttf", ".otf", ".eot", ".svg"}
IGNORE_DIRS = {
    ".git", ".svn", ".hg", "node_modules", "vendor", "dist", "build", ".next",
    ".nuxt", "coverage", "bin", "obj", ".idea", ".vscode",
}
GENERIC_FAMILIES = {
    "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui",
    "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "math", "fangsong",
}
PROVIDER_PATTERNS = {
    "Google Fonts": re.compile(r"fonts\.(?:googleapis|gstatic)\.com", re.I),
    "Adobe Fonts/Typekit": re.compile(r"(?:use\.typekit\.net|p\.typekit\.net|fonts\.adobe\.com)", re.I),
    "Fontshare": re.compile(r"(?:api\.)?fontshare\.com", re.I),
    "Cloud Typography": re.compile(r"cloud\.typography\.com", re.I),
    "MyFonts": re.compile(r"(?:fast\.fonts\.net|myfonts\.net)", re.I),
    "Fontsource CDN": re.compile(r"cdn\.jsdelivr\.net/.+@fontsource", re.I),
}


@dataclass
class Finding:
    severity: str
    code: str
    message: str
    file: str | None = None
    line: int | None = None
    evidence: str | None = None


@dataclass
class FontAsset:
    path: str
    extension: str
    bytes: int


def iter_files(root: Path) -> Iterable[Path]:
    for current, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
        for name in files:
            path = Path(current) / name
            if path.suffix.lower() in TEXT_EXTENSIONS | FONT_EXTENSIONS:
                yield path


def read_text(path: Path) -> str | None:
    try:
        return path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        try:
            return path.read_text(encoding="latin-1")
        except Exception:
            return None
    except Exception:
        return None


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


def clean_family_name(value: str) -> str:
    return value.strip().strip('"\'').strip()


def split_font_stack(value: str) -> list[str]:
    parts = []
    current = []
    quote = None
    for char in value:
        if quote:
            current.append(char)
            if char == quote:
                quote = None
        elif char in {'"', "'"}:
            quote = char
            current.append(char)
        elif char == ",":
            part = clean_family_name("".join(current))
            if part:
                parts.append(part)
            current = []
        else:
            current.append(char)
    part = clean_family_name("".join(current))
    if part:
        parts.append(part)
    return parts


def extract_blocks(text: str, keyword: str) -> list[tuple[int, int, str]]:
    blocks = []
    for match in re.finditer(re.escape(keyword), text, re.I):
        brace = text.find("{", match.end())
        if brace < 0:
            continue
        depth = 0
        quote = None
        escaped = False
        for index in range(brace, len(text)):
            char = text[index]
            if escaped:
                escaped = False
                continue
            if char == "\\":
                escaped = True
                continue
            if quote:
                if char == quote:
                    quote = None
                continue
            if char in {'"', "'"}:
                quote = char
            elif char == "{":
                depth += 1
            elif char == "}":
                depth -= 1
                if depth == 0:
                    blocks.append((match.start(), index + 1, text[brace + 1:index]))
                    break
    return blocks


def declaration(block: str, name: str) -> str | None:
    match = re.search(rf"(?:^|;)\s*{re.escape(name)}\s*:\s*([^;}}]+)", block, re.I | re.M)
    return match.group(1).strip() if match else None


def inspect_project(root: Path) -> dict:
    findings: list[Finding] = []
    font_assets: list[FontAsset] = []
    families = Counter()
    providers = Counter()
    formats = Counter()
    source_files = 0
    font_face_count = 0
    variable_face_count = 0
    external_imports = []
    usage_weights = defaultdict(set)
    declared_weights = defaultdict(set)

    for path in iter_files(root):
        rel = str(path.relative_to(root))
        ext = path.suffix.lower()
        if ext in FONT_EXTENSIONS and ext != ".svg":
            try:
                size = path.stat().st_size
            except OSError:
                size = 0
            font_assets.append(FontAsset(rel, ext, size))
            formats[ext] += 1
            if ext in {".ttf", ".otf", ".eot"}:
                findings.append(Finding(
                    "medium", "non-woff2-public-asset",
                    "A non-WOFF2 font asset is present. Confirm that it is needed for the target and licensed for web distribution.",
                    rel,
                ))
            continue

        text = read_text(path)
        if text is None:
            continue
        if text.startswith("# Font usage inspection") or (
            "\"source_files_scanned\"" in text
            and "\"font_face_rules\"" in text
            and "\"findings\"" in text
        ):
            continue
        source_files += 1

        for provider, pattern in PROVIDER_PATTERNS.items():
            matches = list(pattern.finditer(text))
            if matches:
                providers[provider] += len(matches)
                for match in matches[:3]:
                    findings.append(Finding(
                        "medium", "third-party-font-provider",
                        f"Third-party font delivery detected: {provider}. Review privacy, CSP, availability, and license scope.",
                        rel, line_number(text, match.start()), match.group(0),
                    ))

        for match in re.finditer(r"@import\s+(?:url\()?\s*['\"]?([^'\")\s;]+)", text, re.I):
            url = match.group(1)
            if "font" in url.lower() or any(p.search(url) for p in PROVIDER_PATTERNS.values()):
                external_imports.append(url)
                findings.append(Finding(
                    "medium", "font-css-import",
                    "Font stylesheet loaded through CSS @import. Prefer a normal stylesheet link or self-hosted @font-face for critical fonts.",
                    rel, line_number(text, match.start()), url,
                ))

        font_face_blocks = extract_blocks(text, "@font-face")
        for offset, _end, block in font_face_blocks:
            font_face_count += 1
            family = declaration(block, "font-family")
            src = declaration(block, "src")
            display = declaration(block, "font-display")
            weight = declaration(block, "font-weight")
            style = declaration(block, "font-style")
            unicode_range = declaration(block, "unicode-range")
            family_clean = clean_family_name(family or "unknown")

            if family:
                families[family_clean] += 1
            if weight:
                declared_weights[family_clean].add(weight)
                if re.search(r"\b\d+\s+\d+\b", weight):
                    variable_face_count += 1
            if not display:
                findings.append(Finding(
                    "high", "missing-font-display",
                    f"@font-face for {family_clean} does not declare font-display.",
                    rel, line_number(text, offset),
                ))
            if not src:
                findings.append(Finding(
                    "critical", "missing-font-source",
                    f"@font-face for {family_clean} has no src declaration.",
                    rel, line_number(text, offset),
                ))
            else:
                src_lower = src.lower()
                if ".woff2" not in src_lower and "format(\"woff2\")" not in src_lower and "format('woff2')" not in src_lower:
                    findings.append(Finding(
                        "medium", "missing-woff2-source",
                        f"@font-face for {family_clean} does not visibly include a WOFF2 source.",
                        rel, line_number(text, offset), src[:180],
                    ))
                if re.search(r"\blocal\s*\(", src, re.I):
                    findings.append(Finding(
                        "low", "local-font-source",
                        f"@font-face for {family_clean} uses local(). Confirm that installed versions cannot produce untested metrics or licensing paths.",
                        rel, line_number(text, offset), src[:180],
                    ))
            if not weight:
                findings.append(Finding(
                    "medium", "implicit-font-weight",
                    f"@font-face for {family_clean} does not explicitly declare font-weight.",
                    rel, line_number(text, offset),
                ))
            if not style:
                findings.append(Finding(
                    "low", "implicit-font-style",
                    f"@font-face for {family_clean} does not explicitly declare font-style.",
                    rel, line_number(text, offset),
                ))
            if unicode_range:
                findings.append(Finding(
                    "low", "unicode-range-present",
                    f"@font-face for {family_clean} uses unicode-range. Verify all product languages, user input, symbols, and combining marks.",
                    rel, line_number(text, offset), unicode_range[:180],
                ))

        masked_text = list(text)
        for start, end, _block in font_face_blocks:
            for index in range(start, end):
                if masked_text[index] != "\n":
                    masked_text[index] = " "
        usage_text = "".join(masked_text)

        for match in re.finditer(r"font-family\s*:\s*([^;}{]+)", usage_text, re.I):
            value = match.group(1).strip()
            stack = split_font_stack(value)
            for family in stack:
                if not family.startswith("var("):
                    families[family] += 1
            if stack and not stack[-1].lower() in GENERIC_FAMILIES and not stack[-1].startswith("var("):
                findings.append(Finding(
                    "medium", "missing-generic-fallback",
                    "font-family stack does not visibly end in a generic family.",
                    rel, line_number(usage_text, match.start()), value[:180],
                ))

        for match in re.finditer(r"font-weight\s*:\s*([^;}{]+)", text, re.I):
            value = match.group(1).strip()
            usage_weights[rel].add(value)

        for match in re.finditer(r"(?:^|[;{])\s*line-height\s*:\s*(\d+(?:\.\d+)?)px\b", text, re.I | re.M):
            findings.append(Finding(
                "medium", "fixed-pixel-line-height",
                "Fixed pixel line-height can clip or fail when text size, language, or wrapping changes. Prefer a unitless value for general text.",
                rel, line_number(text, match.start()), match.group(0).strip(),
            ))

        for match in re.finditer(r"letter-spacing\s*:\s*(-\d+(?:\.\d+)?(?:px|em|rem))", text, re.I):
            findings.append(Finding(
                "low", "negative-letter-spacing",
                "Negative tracking detected. Restrict it to tested display roles and verify small text and translations.",
                rel, line_number(text, match.start()), match.group(0),
            ))

        for match in re.finditer(r"(?:html|body|:root)\s*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)px", text, re.I | re.S):
            findings.append(Finding(
                "medium", "fixed-root-font-size",
                "Root or body font-size uses pixels. Verify browser default-font-size preferences and consider a percentage or rem-based system.",
                rel, line_number(text, match.start()), match.group(0)[-120:],
            ))

        for match in re.finditer(r"font-synthesis\s*:\s*none", text, re.I):
            findings.append(Finding(
                "low", "font-synthesis-disabled",
                "Synthetic styles are disabled. Confirm that every required bold and italic face is actually loaded.",
                rel, line_number(text, match.start()), match.group(0),
            ))

        icon_patterns = [
            r"font-family\s*:\s*['\"]?(?:Font Awesome|Glyphicons|Material Icons|IcoMoon)",
            r"class\s*=\s*['\"][^'\"]*\b(?:fa|glyphicon|icon)-[a-z0-9-]+",
        ]
        for pattern in icon_patterns:
            for match in re.finditer(pattern, text, re.I):
                findings.append(Finding(
                    "medium", "icon-font-detected",
                    "Icon-font usage detected. Verify accessible names, decorative hiding, fallback behavior, and whether semantic SVG can replace it.",
                    rel, line_number(text, match.start()), match.group(0)[:180],
                ))

        for match in re.finditer(r"font-variation-settings\s*:\s*([^;}{]+)", text, re.I):
            findings.append(Finding(
                "low", "custom-variable-settings",
                "Direct font-variation-settings detected. Prefer high-level properties for registered axes and document custom axis ranges.",
                rel, line_number(text, match.start()), match.group(1)[:180],
            ))

    if font_face_count and not font_assets and not providers:
        findings.append(Finding(
            "high", "font-assets-not-found",
            "@font-face rules were found, but no local font assets or recognized external provider were detected in the scanned tree.",
        ))

    if providers and font_assets:
        findings.append(Finding(
            "medium", "mixed-font-delivery",
            "Both local font assets and third-party font providers are present. Check for duplicate families, inconsistent versions, and avoidable downloads.",
        ))

    total_font_bytes = sum(asset.bytes for asset in font_assets)
    if total_font_bytes > 1_000_000:
        findings.append(Finding(
            "medium", "large-font-payload",
            f"Local font assets total {total_font_bytes / 1024 / 1024:.2f} MiB before transfer compression analysis. Review weights, styles, subsets, and variable/static tradeoffs.",
        ))

    severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
    findings.sort(key=lambda item: (severity_order.get(item.severity, 9), item.file or "", item.line or 0, item.code))

    return {
        "root": str(root),
        "summary": {
            "source_files_scanned": source_files,
            "font_face_rules": font_face_count,
            "variable_font_faces": variable_face_count,
            "local_font_assets": len(font_assets),
            "local_font_bytes": total_font_bytes,
            "providers": dict(providers),
            "external_font_imports": external_imports,
            "finding_counts": dict(Counter(f.severity for f in findings)),
        },
        "families": [{"name": name, "occurrences": count} for name, count in families.most_common()],
        "declared_weights": {name: sorted(values) for name, values in sorted(declared_weights.items())},
        "font_assets": [asdict(asset) for asset in font_assets],
        "findings": [asdict(finding) for finding in findings],
    }


def markdown_report(report: dict) -> str:
    summary = report["summary"]
    lines = [
        "# Font usage inspection",
        "",
        f"- Root: `{report['root']}`",
        f"- Source files scanned: {summary['source_files_scanned']}",
        f"- `@font-face` rules: {summary['font_face_rules']}",
        f"- Variable font faces: {summary['variable_font_faces']}",
        f"- Local font assets: {summary['local_font_assets']}",
        f"- Local font asset size: {summary['local_font_bytes'] / 1024:.1f} KiB",
        "",
        "## Providers",
        "",
    ]
    if summary["providers"]:
        for name, count in sorted(summary["providers"].items()):
            lines.append(f"- {name}: {count} reference(s)")
    else:
        lines.append("- No recognized third-party font provider detected.")

    lines.extend(["", "## Families", ""])
    if report["families"]:
        lines.extend(["| Family or token | Occurrences |", "| --- | ---: |"])
        for family in report["families"][:30]:
            safe_name = family["name"].replace("|", "\\|")
            lines.append(f"| `{safe_name}` | {family['occurrences']} |")
    else:
        lines.append("No font-family declarations found.")

    lines.extend(["", "## Findings", ""])
    if not report["findings"]:
        lines.append("No heuristic findings. Manual accessibility, browser, language, performance, and license review is still required.")
    else:
        lines.extend(["| Severity | Code | Location | Finding |", "| --- | --- | --- | --- |"])
        for finding in report["findings"]:
            location = finding["file"] or "project"
            if finding["line"]:
                location += f":{finding['line']}"
            message = finding["message"].replace("|", "\\|")
            lines.append(f"| **{finding['severity'].upper()}** | `{finding['code']}` | `{location}` | {message} |")

    lines.extend([
        "",
        "## Manual follow-up",
        "",
        "- Verify the exact font licenses and authorized sources.",
        "- Test required languages, glyphs, weights, real italics, and fallback behavior.",
        "- Test font loading delayed, blocked, and failed.",
        "- Test zoom, text resizing, WCAG text-spacing overrides, contrast, and reflow.",
        "- Measure transferred font bytes, FCP/LCP, and layout shift in a browser.",
    ])
    return "\n".join(lines) + "\n"


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect a project for font usage and common 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 this file instead of stdout")
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv or sys.argv[1:])
    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 2

    report = inspect_project(root)
    output = json.dumps(report, indent=2, ensure_ascii=False) + "\n" if args.format == "json" else markdown_report(report)

    if args.output:
        Path(args.output).write_text(output, encoding="utf-8")
    else:
        sys.stdout.write(output)
    return 0


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