#!/usr/bin/env python3
"""Static web performance inspector.

The inspector finds repository signals that often require performance review.
It does not measure runtime performance and must not be used to claim Core Web
Vitals compliance.
"""

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, Optional
from urllib.parse import urlparse

TEXT_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".css", ".scss", ".sass", ".less",
    ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".vue", ".svelte",
    ".php", ".cfm", ".cfml", ".jsp", ".jspx", ".aspx", ".ascx",
    ".cshtml", ".vbhtml", ".razor", ".twig", ".hbs", ".handlebars",
    ".njk", ".liquid", ".json", ".yaml", ".yml", ".xml", ".conf",
    ".config", ".htaccess", ".properties", ".toml", ".md",
}

MARKUP_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".php", ".cfm", ".cfml", ".jsp",
    ".jspx", ".aspx", ".ascx", ".cshtml", ".vbhtml", ".razor",
    ".twig", ".hbs", ".handlebars", ".njk", ".liquid", ".vue", ".svelte",
}

SCRIPT_EXTENSIONS = {".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".vue", ".svelte"}
STYLE_EXTENSIONS = {".css", ".scss", ".sass", ".less"}
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".tif", ".tiff"}
FONT_EXTENSIONS = {".woff2", ".woff", ".ttf", ".otf", ".eot"}
MEDIA_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v", ".mp3", ".wav", ".ogg", ".m4a"}
BUILD_DIR_NAMES = {"dist", "build", "public", "wwwroot", "static", "assets", "webroot", "web"}
SKIP_DIRS = {
    ".git", ".svn", ".hg", "node_modules", "vendor", ".next", ".nuxt",
    ".svelte-kit", ".cache", ".parcel-cache", "coverage", ".idea", ".vscode",
    "bin", "obj", "target", "Pods", "DerivedData",
}
MAX_TEXT_BYTES = 2_000_000

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


@dataclass
class Finding:
    rule_id: str
    severity: str
    confidence: str
    area: str
    file: str
    line: int
    evidence: str
    impact: str
    remediation: str
    manual_check: str


class Inspector:
    def __init__(self, root: Path) -> None:
        self.root = root.resolve()
        self.findings: list[Finding] = []
        self.files: list[Path] = []
        self.text_cache: dict[Path, str] = {}
        self.package_data: dict = {}
        self.detected: dict[str, object] = {
            "frameworks": [],
            "performance_tools": [],
            "third_party_origins": [],
            "asset_counts": {},
            "asset_bytes": {},
        }
        self.rule_keys: set[tuple[str, str, int, str]] = set()

    def rel(self, path: Path) -> str:
        try:
            return str(path.resolve().relative_to(self.root)).replace(os.sep, "/")
        except ValueError:
            return str(path)

    def add(
        self,
        rule_id: str,
        severity: str,
        confidence: str,
        area: str,
        path: Path,
        line: int,
        evidence: str,
        impact: str,
        remediation: str,
        manual_check: str,
    ) -> None:
        evidence = " ".join(evidence.strip().split())[:300]
        key = (rule_id, self.rel(path), max(line, 1), evidence)
        if key in self.rule_keys:
            return
        self.rule_keys.add(key)
        self.findings.append(
            Finding(
                rule_id=rule_id,
                severity=severity,
                confidence=confidence,
                area=area,
                file=self.rel(path),
                line=max(line, 1),
                evidence=evidence,
                impact=impact,
                remediation=remediation,
                manual_check=manual_check,
            )
        )

    def collect_files(self) -> None:
        for current, dirs, names in os.walk(self.root):
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".pytest")]
            current_path = Path(current)
            for name in names:
                path = current_path / name
                try:
                    if path.is_file():
                        self.files.append(path)
                except OSError:
                    continue

    def read_text(self, path: Path) -> Optional[str]:
        if path in self.text_cache:
            return self.text_cache[path]
        try:
            if path.stat().st_size > MAX_TEXT_BYTES:
                return None
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            return None
        self.text_cache[path] = text
        return text

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

    def find_line(self, text: str, pattern: str, flags: int = 0) -> tuple[int, str] | None:
        match = re.search(pattern, text, flags)
        if not match:
            return None
        return self.line_number(text, match.start()), match.group(0)

    def inspect(self) -> None:
        self.collect_files()
        self.inspect_manifests()
        self.inspect_asset_inventory()
        for path in self.files:
            suffix = path.suffix.lower()
            if suffix not in TEXT_EXTENSIONS:
                continue
            text = self.read_text(path)
            if text is None:
                continue
            if suffix in MARKUP_EXTENSIONS:
                self.inspect_markup(path, text)
            if suffix in SCRIPT_EXTENSIONS or suffix in MARKUP_EXTENSIONS:
                self.inspect_script_signals(path, text)
            if suffix in STYLE_EXTENSIONS or suffix in MARKUP_EXTENSIONS:
                self.inspect_style_signals(path, text)
            self.inspect_config_signals(path, text)
        self.inspect_project_level()
        self.findings.sort(
            key=lambda f: (-SEVERITY_ORDER.get(f.severity, 0), f.file, f.line, f.rule_id)
        )

    def inspect_manifests(self) -> None:
        package = self.root / "package.json"
        if package.exists():
            try:
                self.package_data = json.loads(package.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError):
                self.package_data = {}
            lock_names = ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"]
            if not any((self.root / name).exists() for name in lock_names):
                self.add(
                    "PERF-BUILD-001", "medium", "high", "build", package, 1,
                    "package.json exists without a recognized lockfile",
                    "Non-reproducible dependency resolution can change bundle cost and runtime behavior between builds.",
                    "Commit the package-manager lockfile and use deterministic CI installation.",
                    "Confirm whether dependency resolution is managed outside the repository.",
                )
            deps = {}
            for key in ("dependencies", "devDependencies", "peerDependencies"):
                value = self.package_data.get(key, {})
                if isinstance(value, dict):
                    deps.update(value)
            framework_map = {
                "react": "React", "react-dom": "React", "vue": "Vue", "@angular/core": "Angular",
                "svelte": "Svelte", "jquery": "jQuery", "backbone": "Backbone", "ember-source": "Ember",
                "next": "Next.js", "nuxt": "Nuxt", "gatsby": "Gatsby", "astro": "Astro",
            }
            frameworks = sorted({label for dep, label in framework_map.items() if dep in deps})
            self.detected["frameworks"] = frameworks
            tools = []
            for dep, label in {
                "web-vitals": "web-vitals", "lighthouse": "Lighthouse",
                "@lhci/cli": "Lighthouse CI", "bundlesize": "bundlesize",
                "size-limit": "size-limit", "webpack-bundle-analyzer": "webpack-bundle-analyzer",
                "source-map-explorer": "source-map-explorer",
            }.items():
                if dep in deps:
                    tools.append(label)
            self.detected["performance_tools"] = sorted(tools)
            if len({f for f in frameworks if f in {"React", "Vue", "Angular", "Svelte", "Backbone", "Ember"}}) > 1:
                self.add(
                    "PERF-ARCH-001", "high", "medium", "architecture", package, 1,
                    f"Multiple UI frameworks detected: {', '.join(frameworks)}",
                    "Duplicate runtimes, rendering systems, and shared dependencies can increase transfer, initialization, and memory cost.",
                    "Map framework ownership by route and DOM subtree; prevent duplicate runtime delivery and plan incremental retirement.",
                    "Confirm whether frameworks are isolated to separate builds or pages before treating this as a defect.",
                )
            heavy_review = {
                "moment": "Moment.js", "lodash": "Lodash", "rxjs": "RxJS", "jquery": "jQuery",
                "chart.js": "Chart.js", "monaco-editor": "Monaco Editor", "pdfjs-dist": "PDF.js",
            }
            for dep, label in heavy_review.items():
                if dep in deps:
                    self.add(
                        "PERF-BUNDLE-001", "low", "low", "bundle", package, 1,
                        f"Review package delivery and route usage for {label} ({dep})",
                        "A capable dependency can be expensive when loaded globally, duplicated, or imported without tree-shaking.",
                        "Measure its compressed transfer and runtime cost; lazy-load or replace only when route data proves material impact.",
                        "Inspect the production bundle before deciding whether this dependency is a problem.",
                    )

    def inspect_asset_inventory(self) -> None:
        counts: Counter[str] = Counter()
        byte_counts: Counter[str] = Counter()
        for path in self.files:
            suffix = path.suffix.lower()
            category = "other"
            if suffix in SCRIPT_EXTENSIONS:
                category = "script"
            elif suffix in STYLE_EXTENSIONS:
                category = "style"
            elif suffix in IMAGE_EXTENSIONS:
                category = "image"
            elif suffix in FONT_EXTENSIONS:
                category = "font"
            elif suffix in MEDIA_EXTENSIONS:
                category = "media"
            elif suffix == ".map":
                category = "source-map"
            counts[category] += 1
            try:
                byte_counts[category] += path.stat().st_size
            except OSError:
                continue

            if suffix in IMAGE_EXTENSIONS | MEDIA_EXTENSIONS | FONT_EXTENSIONS:
                try:
                    size = path.stat().st_size
                except OSError:
                    continue
                if suffix in IMAGE_EXTENSIONS and size > 1_000_000:
                    self.add(
                        "PERF-ASSET-001", "high", "medium", "media", path, 1,
                        f"Image asset is {size / 1024:.0f} KiB",
                        "Large image transfers and decodes can delay rendering and consume memory, especially on mobile devices.",
                        "Verify rendered dimensions and visual quality; generate responsive variants and modern encodings where appropriate.",
                        "Confirm whether this source file is shipped to browsers and inspect compressed transfer size in production.",
                    )
                elif suffix in IMAGE_EXTENSIONS and size > 400_000:
                    self.add(
                        "PERF-ASSET-002", "medium", "medium", "media", path, 1,
                        f"Image asset is {size / 1024:.0f} KiB",
                        "The asset may be oversized for its rendered use or mobile cohort.",
                        "Measure delivered candidate size and create responsive variants if the production transfer is excessive.",
                        "Confirm whether the file is a source asset or a browser-delivered asset.",
                    )
                if suffix in MEDIA_EXTENSIONS and size > 5_000_000:
                    self.add(
                        "PERF-ASSET-003", "medium", "medium", "media", path, 1,
                        f"Media asset is {size / 1024 / 1024:.1f} MiB",
                        "Large media can consume bandwidth before user intent and compete with critical resources.",
                        "Review poster, preload, streaming, lazy activation, codec, and adaptive delivery strategy.",
                        "Check actual preload and autoplay behavior in the rendered page.",
                    )
                if suffix in {".ttf", ".otf", ".eot"}:
                    self.add(
                        "PERF-FONT-001", "medium", "high", "fonts", path, 1,
                        f"Legacy or desktop-oriented web font format detected: {suffix}",
                        "Legacy formats can increase payload and complicate font delivery compared with WOFF2.",
                        "Provide a licensed WOFF2 web build, subset deliberately, and retain older formats only for an explicit browser requirement.",
                        "Verify licensing and the supported browser matrix before conversion or removal.",
                    )
                if suffix == ".map" and any(part.lower() in BUILD_DIR_NAMES for part in path.parts):
                    self.add(
                        "PERF-BUILD-002", "low", "medium", "build", path, 1,
                        "Source map found in a browser asset directory",
                        "Source maps can increase deployment size and may be downloaded or exposed depending on server configuration.",
                        "Decide whether production source maps are private, hidden, uploaded to observability tooling, or intentionally public.",
                        "Check network delivery and sourceMappingURL behavior; file presence alone does not prove user transfer.",
                    )
        self.detected["asset_counts"] = dict(counts)
        self.detected["asset_bytes"] = dict(byte_counts)

    def inspect_markup(self, path: Path, text: str) -> None:
        lower = text.lower()
        head_end = lower.find("</head>")
        head_text = text[:head_end] if head_end >= 0 else text

        script_pattern = re.compile(r"<script\b([^>]*)>(.*?)</script\s*>", re.I | re.S)
        third_party_origins: Counter[str] = Counter()
        preload_count = 0
        first_img_seen = False

        for match in script_pattern.finditer(text):
            attrs = match.group(1)
            body = match.group(2)
            line = self.line_number(text, match.start())
            src_m = re.search(r"\bsrc\s*=\s*(['\"])(.*?)\1", attrs, re.I | re.S)
            if src_m:
                src = src_m.group(2).strip()
                is_head = match.start() < (head_end if head_end >= 0 else len(head_text))
                has_defer = re.search(r"\bdefer\b", attrs, re.I) is not None
                has_async = re.search(r"\basync\b", attrs, re.I) is not None
                is_module = re.search(r"\btype\s*=\s*(['\"])module\1", attrs, re.I) is not None
                if is_head and not (has_defer or has_async or is_module):
                    self.add(
                        "PERF-LOAD-001", "high", "high", "loading", path, line,
                        f"Head script without defer/async/module: {src}",
                        "A classic external script in the document head blocks HTML parsing and can delay first render and LCP.",
                        "Use defer for ordered application scripts, async only for independent scripts, or move non-critical loading out of the critical path.",
                        "Confirm whether server-side rewriting or framework tooling adds non-blocking behavior in production.",
                    )
                origin = self.external_origin(src)
                if origin:
                    third_party_origins[origin] += 1
                    if is_head and not (has_defer or has_async or is_module):
                        self.add(
                            "PERF-3P-001", "high", "high", "third-party", path, line,
                            f"Render-blocking third-party script: {src}",
                            "A remote dependency can delay parsing and make initial rendering dependent on another origin.",
                            "Confirm business necessity and supported integration; defer, consent-gate, facade, or remove it when the journey permits.",
                            "Measure its network, execution, and failure cost in a production-like trace.",
                        )
                if re.search(r"(?:@latest|/latest/|/master/|/main/)(?:[^A-Za-z0-9]|$)", src, re.I):
                    self.add(
                        "PERF-3P-002", "medium", "high", "third-party", path, line,
                        f"Unpinned remote script URL: {src}",
                        "An unpinned asset can change size and behavior without an application release, causing performance and reliability regressions.",
                        "Pin an approved version or self-host according to licensing, update, security, and operational requirements.",
                        "Confirm whether the CDN URL resolves to immutable content.",
                    )
            elif len(body.encode("utf-8", errors="ignore")) > 20_000:
                self.add(
                    "PERF-JS-001", "medium", "high", "javascript", path, line,
                    f"Large inline script block: {len(body.encode('utf-8', errors='ignore')) / 1024:.0f} KiB",
                    "Large inline JavaScript increases HTML transfer and parsing and cannot be cached independently.",
                    "Measure whether it is critical; externalize cacheable code, remove duplicated data, or reduce initialization work.",
                    "Inspect the production response because templates may minify or conditionally emit this block.",
                )

        link_pattern = re.compile(r"<link\b([^>]*)>", re.I | re.S)
        for match in link_pattern.finditer(text):
            attrs = match.group(1)
            line = self.line_number(text, match.start())
            rel_m = re.search(r"\brel\s*=\s*(['\"])(.*?)\1", attrs, re.I | re.S)
            href_m = re.search(r"\bhref\s*=\s*(['\"])(.*?)\1", attrs, re.I | re.S)
            rel = rel_m.group(2).lower().split() if rel_m else []
            href = href_m.group(2).strip() if href_m else ""
            if "preload" in rel:
                preload_count += 1
                as_m = re.search(r"\bas\s*=\s*(['\"])(.*?)\1", attrs, re.I | re.S)
                if not as_m:
                    self.add(
                        "PERF-HINT-001", "high", "high", "loading", path, line,
                        f"Preload without as attribute: {href or '<unknown>'}",
                        "A malformed preload may be ignored, fetched with the wrong destination, or downloaded again.",
                        "Set the exact `as` destination and matching type/crossorigin attributes, or remove the preload.",
                        "Verify the request in the production waterfall and console.",
                    )
                if re.search(r"\bas\s*=\s*(['\"])font\1", attrs, re.I) and not re.search(r"\bcrossorigin(?:\s*=|\s|$)", attrs, re.I):
                    self.add(
                        "PERF-HINT-002", "high", "high", "fonts", path, line,
                        f"Font preload without crossorigin: {href or '<unknown>'}",
                        "Font fetches use CORS mode; a mismatched preload can cause a duplicate request.",
                        "Add the correct crossorigin attribute and ensure the preload URL exactly matches the CSS font source.",
                        "Confirm server CORS headers and the production request initiators.",
                    )
            origin = self.external_origin(href)
            if origin:
                third_party_origins[origin] += 1

        if preload_count > 6:
            self.add(
                "PERF-HINT-003", "medium", "medium", "loading", path, 1,
                f"Page declares {preload_count} preload hints",
                "Too many high-priority resources can compete with the document, CSS, LCP media, and other truly critical requests.",
                "Review each preload against the critical request chain and remove hints that do not improve comparable traces.",
                "Measure request priority and duplicate downloads in a production-like waterfall.",
            )

        img_pattern = re.compile(r"<img\b([^>]*)>", re.I | re.S)
        for match in img_pattern.finditer(text):
            attrs = match.group(1)
            line = self.line_number(text, match.start())
            src_m = re.search(r"\bsrc\s*=\s*(['\"])(.*?)\1", attrs, re.I | re.S)
            src = src_m.group(2).strip() if src_m else "<dynamic>"
            classes = " ".join(re.findall(r"\bclass\s*=\s*(['\"])(.*?)\1", attrs, re.I | re.S)[0][1:]) if re.search(r"\bclass\s*=", attrs, re.I) else ""
            has_width = re.search(r"\bwidth\s*=", attrs, re.I) is not None
            has_height = re.search(r"\bheight\s*=", attrs, re.I) is not None
            has_style_ratio = re.search(r"aspect-ratio\s*:", attrs, re.I) is not None
            if not ((has_width and has_height) or has_style_ratio):
                self.add(
                    "PERF-CLS-001", "high", "high", "visual-stability", path, line,
                    f"Image lacks intrinsic width/height or inline aspect ratio: {src}",
                    "The browser may not reserve stable space before the image loads, contributing to layout shifts.",
                    "Emit intrinsic width and height or reserve an equivalent stable aspect ratio while allowing responsive CSS sizing.",
                    "Check whether stylesheet rules or the CMS provide a stable aspect ratio at every breakpoint.",
                )
            lazy = re.search(r"\bloading\s*=\s*(['\"])lazy\1", attrs, re.I) is not None
            high_priority = re.search(r"\bfetchpriority\s*=\s*(['\"])high\1", attrs, re.I) is not None
            likely_hero = re.search(r"(?:hero|banner|masthead|lcp|cover)", attrs, re.I) is not None
            if lazy and high_priority:
                self.add(
                    "PERF-LCP-001", "high", "high", "loading", path, line,
                    f"Image combines loading=lazy with fetchpriority=high: {src}",
                    "The hints express conflicting intent and can delay a critical image or waste priority.",
                    "Determine whether the image is initially critical. Remove lazy loading for likely LCP media, otherwise remove high priority.",
                    "Confirm the actual LCP element and browser request priority in a trace.",
                )
            if lazy and (likely_hero or not first_img_seen):
                self.add(
                    "PERF-LCP-002", "high", "medium", "loading", path, line,
                    f"First or hero-like image is lazy-loaded: {src}",
                    "Lazy loading a likely above-the-fold or LCP image can delay discovery and LCP.",
                    "Load initial critical media eagerly and keep lazy loading for offscreen content.",
                    "Verify viewport placement and the actual LCP candidate before changing behavior.",
                )
            if src not in {"<dynamic>", ""} and not src.startswith("data:") and not re.search(r"\bsrcset\s*=", attrs, re.I):
                self.add(
                    "PERF-IMG-001", "low", "low", "media", path, line,
                    f"Image has no srcset: {src}",
                    "A single raster source may deliver excessive pixels to small viewports or insufficient quality to high-density displays.",
                    "Review rendered size and asset pipeline; add responsive candidates and a correct sizes attribute when beneficial.",
                    "This is not a defect for SVG, tiny assets, or images already transformed by runtime/CDN logic.",
                )
            first_img_seen = True

        iframe_pattern = re.compile(r"<iframe\b([^>]*)>", re.I | re.S)
        for match in iframe_pattern.finditer(text):
            attrs = match.group(1)
            line = self.line_number(text, match.start())
            if not re.search(r"\bloading\s*=\s*(['\"])lazy\1", attrs, re.I):
                self.add(
                    "PERF-EMBED-001", "medium", "medium", "third-party", path, line,
                    "Iframe without loading=lazy",
                    "An offscreen embed can start network, rendering, and script work before user intent.",
                    "Lazy-load non-critical iframes or use an accessible activation facade; keep critical embeds eager only when required.",
                    "Confirm whether the iframe is initially visible or transaction-critical.",
                )
            if not re.search(r"(?:\bwidth\s*=|aspect-ratio\s*:)", attrs, re.I) or not re.search(r"(?:\bheight\s*=|aspect-ratio\s*:)", attrs, re.I):
                self.add(
                    "PERF-CLS-002", "medium", "medium", "visual-stability", path, line,
                    "Iframe does not visibly reserve both dimensions",
                    "Embed insertion or loading may shift surrounding content.",
                    "Reserve a stable aspect ratio or dimensions for each responsive state.",
                    "Inspect computed styles because dimensions may be supplied by CSS.",
                )

        video_pattern = re.compile(r"<video\b([^>]*)>", re.I | re.S)
        for match in video_pattern.finditer(text):
            attrs = match.group(1)
            line = self.line_number(text, match.start())
            if re.search(r"\bautoplay\b", attrs, re.I) and not re.search(r"\bpreload\s*=\s*(['\"])(?:none|metadata)\1", attrs, re.I):
                self.add(
                    "PERF-MEDIA-001", "medium", "medium", "media", path, line,
                    "Autoplay video without conservative preload metadata",
                    "Video can consume substantial bandwidth and decoding resources during the critical load.",
                    "Validate whether motion is essential; provide poster and appropriate preload, and honor reduced-motion and data constraints.",
                    "Check the actual media source, autoplay policy, viewport placement, and network behavior.",
                )

        if len(third_party_origins) > 5:
            origins = ", ".join(sorted(third_party_origins)[:8])
            self.add(
                "PERF-3P-003", "high", "medium", "third-party", path, 1,
                f"Markup references {len(third_party_origins)} external origins: {origins}",
                "Many origins add connection setup, variability, privacy surface, and competing resource work.",
                "Inventory ownership and purpose; remove duplicates, consent-gate, consolidate, facade, or delay non-critical integrations.",
                "Measure transfer and main-thread contribution because origin count alone does not prove excessive cost.",
            )
        if third_party_origins:
            all_origins = set(self.detected.get("third_party_origins", []))
            all_origins.update(third_party_origins)
            self.detected["third_party_origins"] = sorted(all_origins)

        unload = self.find_line(text, r"(?:addEventListener\s*\(\s*['\"]unload|\bonunload\s*=)", re.I)
        if unload:
            self.add(
                "PERF-BFCACHE-001", "high", "high", "navigation", path, unload[0], unload[1],
                "Unload handlers can prevent or complicate back-forward cache use and delay page dismissal.",
                "Replace unload logic with appropriate page lifecycle events and sendBeacon or fetch keepalive where supported.",
                "Test bfcache eligibility and state restoration in all supported browsers.",
            )

    def inspect_script_signals(self, path: Path, text: str) -> None:
        checks = [
            ("PERF-JS-002", "critical", "high", r"\.open\s*\([^\n]{0,240},\s*false\s*\)", "Synchronous XMLHttpRequest detected", "Synchronous network waits block the main thread and freeze interaction.", "Replace with asynchronous fetch/XHR and explicit pending, timeout, cancellation, and error states.", "Confirm this code executes in browser context and is not test-only."),
            ("PERF-JS-003", "high", "high", r"\bdocument\.write\s*\(", "document.write detected", "It can block parsing, trigger destructive document replacement, and make loading dependent on script timing.", "Replace with server-rendered markup or explicit DOM creation/loading that preserves document parsing.", "Identify any vendor-supported replacement before modifying third-party integrations."),
            ("PERF-JS-004", "high", "high", r"\b(?:eval|new\s+Function)\s*\(", "Dynamic code execution detected", "Dynamic compilation adds parse/compile work and creates security and optimization barriers.", "Remove dynamic code generation and use explicit modules, parsers, or approved template mechanisms.", "Confirm whether the occurrence is in generated, vendor, or test code."),
            ("PERF-JS-005", "high", "medium", r"\bwhile\s*\(\s*true\s*\)", "Potential unbounded loop detected", "An unbounded loop can block rendering and interaction or exhaust resources.", "Add a bounded condition, cancellation, yielding, or move appropriate computation to a worker.", "Review the control flow; some loops intentionally break internally."),
            ("PERF-JS-006", "medium", "medium", r"setInterval\s*\([^,]+,\s*(?:[1-9]|[1-9][0-9])\s*\)", "Very frequent interval detected", "Frequent timers can wake the main thread, consume battery, and compete with rendering.", "Use event-driven updates, requestAnimationFrame for visuals, or a slower/cancellable schedule.", "Check whether the timer runs only briefly or in tests."),
            ("PERF-JS-007", "medium", "medium", r"addEventListener\s*\(\s*['\"](?:scroll|touchmove|wheel)['\"][^\n]{0,260}\)", "High-frequency input listener requires review", "Unthrottled or non-passive handlers can increase interaction and scrolling cost.", "Keep handlers minimal, use passive listeners where semantics permit, and schedule visual work with requestAnimationFrame.", "Inspect listener options and handler implementation; a static match cannot prove it is blocking."),
            ("PERF-JS-008", "medium", "medium", r"getBoundingClientRect\s*\([^)]*\)[\s\S]{0,240}?\.style\.", "Possible layout read followed by style write", "Repeated layout reads and writes can force synchronous layout and cause jank.", "Batch reads, then writes; cache geometry within a frame and avoid repeated measurement inside loops.", "Use a runtime trace to confirm forced layout and execution frequency."),
            ("PERF-JS-009", "medium", "high", r"\bwindow\.onbeforeunload\s*=|addEventListener\s*\(\s*['\"]beforeunload", "beforeunload handler detected", "Persistent beforeunload handlers can affect navigation optimizations and user experience.", "Register only while unsaved state exists, remove promptly, and use lifecycle-safe persistence.", "Test current browser bfcache behavior and confirm the warning is genuinely required."),
        ]
        for rule_id, severity, confidence, pattern, evidence, impact, remediation, manual in checks:
            found = self.find_line(text, pattern, re.I)
            if found:
                self.add(rule_id, severity, confidence, "javascript", path, found[0], found[1] or evidence, impact, remediation, manual)

        if re.search(r"(?:web-vitals|onLCP\s*\(|onINP\s*\(|onCLS\s*\(|PerformanceObserver\s*\()", text, re.I):
            tools = set(self.detected.get("performance_tools", []))
            tools.add("runtime performance instrumentation")
            self.detected["performance_tools"] = sorted(tools)

        if path.suffix.lower() in SCRIPT_EXTENSIONS:
            try:
                size = path.stat().st_size
            except OSError:
                size = 0
            if size > 500_000 and any(part.lower() in BUILD_DIR_NAMES for part in path.parts):
                self.add(
                    "PERF-BUNDLE-002", "high", "medium", "bundle", path, 1,
                    f"Browser script asset is {size / 1024:.0f} KiB uncompressed on disk",
                    "A large script may delay transfer, parsing, compilation, and main-thread execution.",
                    "Inspect the production bundle composition, compressed transfer, route usage, duplicate modules, and initialization cost.",
                    "Confirm whether the file is shipped, minified, compressed, and required on the critical route.",
                )
            elif size > 250_000 and any(part.lower() in BUILD_DIR_NAMES for part in path.parts):
                self.add(
                    "PERF-BUNDLE-003", "medium", "medium", "bundle", path, 1,
                    f"Browser script asset is {size / 1024:.0f} KiB uncompressed on disk",
                    "The script may be a material route cost depending on compression and execution.",
                    "Measure bundle composition and runtime initialization before setting a route-specific budget.",
                    "Confirm actual production delivery and caching.",
                )

    def inspect_style_signals(self, path: Path, text: str) -> None:
        checks = [
            ("PERF-CSS-001", "medium", "high", r"@import\s+(?:url\()?['\"][^'\"]+['\"]", "CSS @import detected", "Browser CSS imports can create serial request chains after the parent stylesheet is downloaded and parsed.", "Bundle or link critical styles directly; retain imports only when the delivery architecture proves they are not on the critical path.", "Distinguish browser CSS imports from Sass/Less build-time imports."),
            ("PERF-CSS-002", "medium", "high", r"@font-face\s*\{(?:(?!\}).)*?(?!font-display\s*:)[^}]*\}", "@font-face may omit font-display", "Default font loading behavior can delay text or create unpredictable swaps.", "Choose font-display deliberately and validate fallback metrics, readability, and layout stability.", "The regular expression is heuristic; inspect the complete rule and generated CSS."),
            ("PERF-CSS-003", "medium", "high", r"transition\s*:\s*all\b", "transition: all detected", "Animating all changed properties can trigger unnecessary layout, paint, or compositing work and make behavior hard to predict.", "List only intended properties and verify reduced-motion behavior.", "Profile the actual transition because not every property change is expensive."),
            ("PERF-CSS-004", "medium", "medium", r"will-change\s*:\s*(?:all|transform[^;]*,|[^;]*,[^;]*)", "Broad or multiple will-change hint detected", "Persistent promotion hints can consume memory and create excessive compositing layers.", "Apply will-change shortly before a verified animation and remove it afterward; avoid global rules.", "Inspect computed styles and layer memory in DevTools."),
            ("PERF-CSS-005", "medium", "medium", r"(?:width|min-width)\s*:\s*[1-9][0-9]{3,}px", "Very wide fixed CSS dimension detected", "Rigid widths can force overflow, additional layout work, and poor zoom or mobile behavior.", "Use intrinsic sizing, max-inline-size, grid/flex constraints, and content-driven breakpoints.", "Confirm whether the rule targets print, canvas, a data grid, or another intrinsically wide surface."),
        ]
        for rule_id, severity, confidence, pattern, evidence, impact, remediation, manual in checks:
            found = self.find_line(text, pattern, re.I | re.S)
            if found:
                self.add(rule_id, severity, confidence, "css", path, found[0], found[1] or evidence, impact, remediation, manual)

        if path.suffix.lower() == ".css":
            try:
                size = path.stat().st_size
            except OSError:
                size = 0
            if size > 250_000 and any(part.lower() in BUILD_DIR_NAMES for part in path.parts):
                self.add(
                    "PERF-CSS-006", "high", "medium", "css", path, 1,
                    f"Browser stylesheet is {size / 1024:.0f} KiB uncompressed on disk",
                    "Large render-blocking CSS can delay style calculation and initial render.",
                    "Measure used CSS by route, remove duplication, and split or inline only with ordering and cache behavior verified.",
                    "Confirm minification, compression, cache, and whether this file is loaded on the critical route.",
                )

    def inspect_config_signals(self, path: Path, text: str) -> None:
        name = path.name.lower()
        if name in {"nginx.conf", ".htaccess", "web.config", "httpd.conf"} or name.endswith(".conf"):
            found = self.find_line(text, r"Cache-Control[^\n]*(?:no-store|max-age\s*=\s*0)", re.I)
            if found:
                self.add(
                    "PERF-CACHE-001", "medium", "medium", "cache", path, found[0], found[1],
                    "A broad no-store or zero-freshness rule can prevent reuse and may reduce navigation optimizations.",
                    "Scope cache directives by resource semantics; use fingerprinted immutable caching for static assets and safe validation for documents/data.",
                    "Determine the matching location and whether the rule protects sensitive or transactional responses.",
                )
            if re.search(r"gzip\s+off|brotli\s+off", text, re.I):
                line, ev = self.find_line(text, r"(?:gzip|brotli)\s+off", re.I) or (1, "compression disabled")
                self.add(
                    "PERF-NET-001", "medium", "medium", "network", path, line, ev,
                    "Disabling response compression can increase transfer size for text resources.",
                    "Verify upstream/CDN compression before enabling supported compression for HTML, CSS, JS, JSON, SVG, and other text formats.",
                    "Production compression may be handled outside this configuration.",
                )

        if name in {"lighthouserc.js", "lighthouserc.cjs", "lighthouserc.json", "lighthouserc.yml", "lighthouserc.yaml", "budgets.json"}:
            tools = set(self.detected.get("performance_tools", []))
            tools.add("performance CI configuration")
            self.detected["performance_tools"] = sorted(tools)

        if path.suffix.lower() in {".json", ".js", ".cjs", ".mjs", ".yaml", ".yml"}:
            if re.search(r"devtool\s*[:=]\s*['\"](?:inline-source-map|eval|eval-source-map)", text, re.I):
                line, ev = self.find_line(text, r"devtool\s*[:=]\s*['\"][^'\"]+", re.I) or (1, "development source map mode")
                self.add(
                    "PERF-BUILD-003", "high", "medium", "build", path, line, ev,
                    "Development-oriented source map modes can greatly inflate and slow browser bundles if used in production.",
                    "Use a production build profile and an intentional source-map strategy.",
                    "Confirm environment-specific configuration and the deployed artifact.",
                )

    def inspect_project_level(self) -> None:
        app_signals = any(
            (self.root / name).exists()
            for name in ("package.json", "composer.json", "pom.xml", "build.gradle", "*.csproj")
        ) or any(p.suffix.lower() in MARKUP_EXTENSIONS for p in self.files)

        tools = set(self.detected.get("performance_tools", []))
        has_rum = any(
            re.search(r"(?:web-vitals|onLCP\s*\(|onINP\s*\(|onCLS\s*\(|PerformanceObserver\s*\()", self.read_text(p) or "", re.I)
            for p in self.files
            if p.suffix.lower() in TEXT_EXTENSIONS
        )
        has_ci = bool(tools & {"Lighthouse CI", "performance CI configuration", "bundlesize", "size-limit"})
        representative = self.root / "package.json" if (self.root / "package.json").exists() else (self.files[0] if self.files else self.root)
        if app_signals and not has_rum:
            self.add(
                "PERF-OBS-001", "medium", "medium", "observability", representative, 1,
                "No obvious Core Web Vitals or PerformanceObserver instrumentation found",
                "Without field instrumentation, regressions affecting real users, devices, routes, and interactions may remain invisible.",
                "Add privacy-reviewed RUM or document the external telemetry source; capture release, route, lifecycle, and bounded attribution.",
                "Instrumentation may be injected by the platform, tag manager, CDN, or observability agent outside this repository.",
            )
        if app_signals and not has_ci:
            self.add(
                "PERF-CI-001", "medium", "medium", "governance", representative, 1,
                "No obvious performance budget or Lighthouse CI configuration found",
                "Performance regressions may reach production without route-specific automated warnings or gates.",
                "Introduce repeatable lab smoke tests and route-specific budgets, beginning with warnings and ratcheting legacy baselines.",
                "Performance checks may exist in an external CI repository or service.",
            )

        map_files = [p for p in self.files if p.suffix.lower() == ".map"]
        if len(map_files) > 50:
            self.add(
                "PERF-BUILD-004", "low", "medium", "build", map_files[0], 1,
                f"Repository contains {len(map_files)} source-map files",
                "Large generated artifacts can inflate deployments and complicate cache invalidation if copied wholesale.",
                "Separate build artifacts from source and deploy only intentional production outputs.",
                "Confirm whether these files are ignored or excluded by the deployment pipeline.",
            )

    @staticmethod
    def external_origin(url: str) -> Optional[str]:
        url = url.strip()
        if url.startswith("//"):
            url = "https:" + url
        parsed = urlparse(url)
        if parsed.scheme in {"http", "https"} and parsed.netloc:
            return f"{parsed.scheme}://{parsed.netloc.lower()}"
        return None

    def report(self) -> dict:
        counts = Counter(f.severity for f in self.findings)
        areas = Counter(f.area for f in self.findings)
        return {
            "tool": "inspect_web_performance.py",
            "root": str(self.root),
            "disclaimer": "Static evidence only. This report does not measure runtime performance or establish Core Web Vitals compliance.",
            "summary": {
                "files_scanned": len(self.files),
                "findings": len(self.findings),
                "by_severity": dict(sorted(counts.items(), key=lambda item: -SEVERITY_ORDER.get(item[0], 0))),
                "by_area": dict(areas.most_common()),
            },
            "detected": self.detected,
            "findings": [asdict(f) for f in self.findings],
        }


def render_markdown(report: dict) -> str:
    summary = report["summary"]
    lines = [
        "# Web Performance UI Static Inspection",
        "",
        f"- Root: `{report['root']}`",
        f"- Files scanned: {summary['files_scanned']}",
        f"- Findings: {summary['findings']}",
        f"- Disclaimer: {report['disclaimer']}",
        "",
        "## Severity summary",
        "",
        "| Severity | Count |",
        "| --- | ---: |",
    ]
    for severity in ("critical", "high", "medium", "low", "info"):
        if severity in summary["by_severity"]:
            lines.append(f"| {severity.title()} | {summary['by_severity'][severity]} |")

    detected = report.get("detected", {})
    lines.extend(["", "## Detected context", ""])
    lines.append(f"- Frameworks: {', '.join(detected.get('frameworks', [])) or 'none detected'}")
    lines.append(f"- Performance tooling: {', '.join(detected.get('performance_tools', [])) or 'none detected'}")
    lines.append(f"- External origins: {', '.join(detected.get('third_party_origins', [])) or 'none detected'}")
    asset_counts = detected.get("asset_counts", {})
    if asset_counts:
        lines.append("- Asset counts: " + ", ".join(f"{k}={v}" for k, v in sorted(asset_counts.items())))

    lines.extend(["", "## Findings", ""])
    if not report["findings"]:
        lines.append("No static findings were generated. Runtime measurement is still required.")
    for finding in report["findings"]:
        lines.extend(
            [
                f"### [{finding['severity'].upper()}] {finding['rule_id']} - {finding['area']}",
                "",
                f"- Location: `{finding['file']}:{finding['line']}`",
                f"- Confidence: {finding['confidence']}",
                f"- Evidence: `{finding['evidence']}`",
                f"- Impact: {finding['impact']}",
                f"- Remediation: {finding['remediation']}",
                f"- Manual check: {finding['manual_check']}",
                "",
            ]
        )
    return "\n".join(lines).rstrip() + "\n"


def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect a repository for static web performance signals.")
    parser.add_argument("path", help="Project directory to inspect")
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--output", help="Write output to this file instead of stdout")
    parser.add_argument(
        "--fail-on",
        choices=("critical", "high", "medium", "low"),
        help="Exit with status 2 when a finding at or above this severity exists",
    )
    return parser.parse_args(argv)


def main(argv: Optional[Iterable[str]] = None) -> int:
    args = parse_args(argv)
    root = Path(args.path)
    if not root.exists() or not root.is_dir():
        print(f"error: project directory not found: {root}", file=sys.stderr)
        return 1

    inspector = Inspector(root)
    inspector.inspect()
    report = inspector.report()
    output = json.dumps(report, indent=2, ensure_ascii=False) + "\n" if args.format == "json" else render_markdown(report)

    if args.output:
        output_path = Path(args.output)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(output, encoding="utf-8")
    else:
        sys.stdout.write(output)

    if args.fail_on:
        threshold = SEVERITY_ORDER[args.fail_on]
        if any(SEVERITY_ORDER.get(f.severity, 0) >= threshold for f in inspector.findings):
            return 2
    return 0


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