#!/usr/bin/env python3
"""Static inventory and heuristic audit for icons and visual assets.

The scanner uses only the Python standard library. Findings are evidence for
review, not accessibility, performance, security, or license certification.
"""

from __future__ import annotations

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

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

ASSET_EXTENSIONS = {
    ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico",
    ".bmp", ".tif", ".tiff", ".psd", ".ai", ".eps", ".heic", ".heif",
    ".woff", ".woff2", ".ttf", ".otf", ".eot",
}
TEXT_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".php", ".cfm", ".cfml", ".jsp", ".jspx",
    ".aspx", ".ascx", ".cshtml", ".vbhtml", ".razor", ".vue", ".svelte",
    ".jsx", ".tsx", ".js", ".mjs", ".cjs", ".ts", ".css", ".scss",
    ".sass", ".less", ".json", ".jsonc", ".xml", ".md", ".yaml", ".yml",
}
HTML_LIKE = {".html", ".htm", ".xhtml", ".php", ".cfm", ".cfml", ".jsp", ".jspx", ".aspx", ".ascx", ".cshtml", ".vbhtml", ".razor", ".vue", ".svelte", ".jsx", ".tsx"}
CSS_LIKE = {".css", ".scss", ".sass", ".less", ".vue", ".svelte"}
JS_LIKE = {".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx", ".vue", ".svelte"}
SKIP_DIRS = {
    ".git", ".svn", ".hg", "node_modules", "vendor", "dist", "build", "coverage",
    ".next", ".nuxt", ".output", "target", "bin", "obj", ".idea", ".vscode",
    "__pycache__", ".cache", ".turbo", ".parcel-cache",
}
LICENSE_NAMES = {
    "license", "license.md", "license.txt", "licenses", "notice", "notice.md",
    "notice.txt", "third-party-notices", "third-party-notices.md", "third_party_notices.txt",
    "credits", "credits.md", "attribution", "attribution.md",
}
ICON_LIBRARY_PATTERNS = {
    "bootstrap-icons": re.compile(r"bootstrap-icons", re.I),
    "font-awesome": re.compile(r"fontawesome|font-awesome|@fortawesome", re.I),
    "material-icons": re.compile(r"material-icons|material-symbols|material-design-icons", re.I),
    "heroicons": re.compile(r"heroicons", re.I),
    "lucide": re.compile(r"lucide", re.I),
    "react-icons": re.compile(r"react-icons", re.I),
    "feather": re.compile(r"feather-icons|feathericons", re.I),
    "phosphor": re.compile(r"phosphor-icons|@phosphor-icons", re.I),
    "tabler-icons": re.compile(r"tabler-icons|@tabler/icons", re.I),
    "glyphicons": re.compile(r"glyphicons|glyphicon", re.I),
}


@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 = ""


@dataclass
class AssetRecord:
    path: str
    extension: str
    bytes: int
    width: Optional[int] = None
    height: Optional[int] = None
    sha256: str = ""


class Scanner:
    def __init__(self, root: Path, max_text_bytes: int, max_files: int) -> None:
        self.root = root.resolve()
        self.max_text_bytes = max_text_bytes
        self.max_files = max_files
        self.findings: list[Finding] = []
        self.assets: list[AssetRecord] = []
        self.files_scanned = 0
        self.text_files = 0
        self.skipped_large_text = 0
        self.icon_libraries: set[str] = set()
        self.package_manifests: list[str] = []
        self.asset_directories: set[str] = set()
        self.has_license_file = False
        self.has_asset_manifest = False
        self.has_responsive_images = False
        self.has_picture = False
        self.has_svg_sprite = False
        self.has_modern_raster = False
        self.has_legacy_icon_font = False
        self.has_visual_tests = False
        self.inline_svg_id_locations: dict[str, list[tuple[str, int]]] = defaultdict(list)
        self.asset_hashes: dict[str, list[AssetRecord]] = defaultdict(list)

    def rel(self, path: Path) -> str:
        try:
            return path.resolve().relative_to(self.root).as_posix()
        except ValueError:
            return path.as_posix()

    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:
        self.findings.append(
            Finding(
                rule_id=rule_id,
                severity=severity,
                confidence=confidence,
                area=area,
                file=self.rel(path),
                line=max(1, line),
                evidence=compact(evidence, 240),
                impact=impact,
                remediation=remediation,
                manual_check=manual_check,
            )
        )

    def walk(self) -> list[Path]:
        result: list[Path] = []
        for current, dirs, files in os.walk(self.root):
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".git")]
            for name in files:
                path = Path(current) / name
                if path.is_symlink():
                    continue
                result.append(path)
                if len(result) >= self.max_files:
                    return result
        return result

    def run(self) -> dict:
        files = self.walk()
        self.files_scanned = len(files)
        for path in files:
            lower_name = path.name.lower()
            if lower_name in LICENSE_NAMES or lower_name.startswith("license.") or lower_name.startswith("notice."):
                self.has_license_file = True
            if re.search(r"(?:asset|icon|image).*(?:manifest|inventory|catalog)|(?:manifest|inventory).*(?:asset|icon|image)", lower_name):
                self.has_asset_manifest = True
            if re.search(r"visual|screenshot|chromatic|playwright", lower_name, re.I) and path.suffix.lower() in TEXT_EXTENSIONS:
                self.has_visual_tests = True

            ext = path.suffix.lower()
            if ext in ASSET_EXTENSIONS:
                self.scan_asset(path)
            if ext in TEXT_EXTENSIONS:
                self.scan_text(path)

        self.post_process()
        self.findings.sort(key=lambda f: (-SEVERITY_ORDER.get(f.severity, 0), f.file, f.line, f.rule_id))
        return self.report()

    def scan_asset(self, path: Path) -> None:
        ext = path.suffix.lower()
        try:
            size = path.stat().st_size
        except OSError:
            return
        width, height = read_image_dimensions(path)
        digest = ""
        try:
            digest = sha256_file(path)
        except OSError:
            pass
        record = AssetRecord(self.rel(path), ext, size, width, height, digest)
        self.assets.append(record)
        if digest:
            self.asset_hashes[digest].append(record)
        self.asset_directories.add(self.rel(path.parent))

        if ext in {".webp", ".avif"}:
            self.has_modern_raster = True

        name = path.name.lower()
        if ext in {".woff", ".woff2", ".ttf", ".otf", ".eot"} and re.search(r"icon|glyph|awesome|material|symbol", name):
            self.has_legacy_icon_font = True
            self.add(
                "ASSET-FONT-001", "medium", "high", "icon-font", path, 1,
                f"Icon-like font asset: {path.name} ({format_bytes(size)}).",
                "Icon fonts couple interface meaning to font loading, codepoints, and CSS classes.",
                "Confirm this is a required compatibility layer. Prefer SVG components or sprites for new work and document a retirement map.",
                "Test font failure, private-use codepoints, copy/paste, CSP, forced colors, and accessible naming.",
            )

        if ext == ".svg":
            self.scan_svg_file(path, size)
        elif ext in {".bmp", ".tif", ".tiff", ".psd", ".ai", ".eps", ".heic", ".heif"}:
            self.add(
                "ASSET-SOURCE-001", "high" if ext in {".psd", ".ai", ".eps"} else "medium", "high", "pipeline", path, 1,
                f"Source or broadly unsuitable runtime format found: {path.name}.",
                "Editable masters or poorly supported formats can leak into deployable bundles and create large or unusable downloads.",
                "Keep masters in a source-only directory and generate reviewed deployable derivatives.",
                "Confirm whether this directory is excluded from production packaging.",
            )
        else:
            if size > 3 * 1024 * 1024:
                self.add(
                    "ASSET-SIZE-001", "high", "high", "performance", path, 1,
                    f"Large visual asset: {format_bytes(size)}.",
                    "Large transfers and decode cost can delay rendering and consume memory, especially on mobile devices.",
                    "Verify rendered dimensions and visual quality, then resize, recompress, split, lazy-load, or replace as appropriate.",
                    "Measure route-level transfer and decode cost before and after optimization.",
                )
            elif size > 750 * 1024:
                self.add(
                    "ASSET-SIZE-002", "medium", "high", "performance", path, 1,
                    f"Substantial visual asset: {format_bytes(size)}.",
                    "The asset may be larger than required for its rendered slot or delivery priority.",
                    "Check actual display size, candidate widths, encoding quality, and cache behavior.",
                )
            if width and height and width * height > 20_000_000:
                self.add(
                    "ASSET-PIXELS-001", "medium", "high", "performance", path, 1,
                    f"High pixel count: {width}x{height} ({width * height:,} pixels).",
                    "Large intrinsic dimensions can increase decode memory even when transfer compression is effective.",
                    "Generate derivatives matched to real layout slots and retain the master outside the runtime bundle.",
                )

    def scan_svg_file(self, path: Path, size: int) -> None:
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            return
        low = text.lower()
        root_match = re.search(r"<svg\b[^>]*>", text, re.I | re.S)
        root = root_match.group(0) if root_match else ""
        if not re.search(r"\bviewBox\s*=", root, re.I):
            self.add(
                "SVG-VIEWBOX-001", "high", "high", "svg", path, line_for(text, root_match.start() if root_match else 0),
                root or "SVG root not detected.",
                "Without a viewBox, scaling and consistent sizing can fail across components and responsive layouts.",
                "Add a viewBox matching the intended coordinate system and verify every delivery mode.",
            )
        if re.search(r"<script\b|\bon\w+\s*=\s*['\"]|javascript\s*:", text, re.I):
            m = re.search(r"<script\b|\bon\w+\s*=\s*['\"]|javascript\s*:", text, re.I)
            self.add(
                "SVG-ACTIVE-001", "critical", "high", "security", path, line_for(text, m.start() if m else 0),
                m.group(0) if m else "Active SVG content.",
                "SVG can contain active content that becomes dangerous in inline, object, or upload rendering paths.",
                "Reject or sanitize the SVG with an allowlist appropriate to the exact embedding mode. Do not inject it as trusted markup.",
                "Review source trust, CSP, content type, upload path, and every rendering context.",
            )
        if "<foreignobject" in low:
            m = re.search(r"<foreignObject\b", text, re.I)
            self.add(
                "SVG-FOREIGNOBJECT-001", "high", "high", "security", path, line_for(text, m.start() if m else 0),
                "SVG contains <foreignObject>.",
                "Embedded HTML increases sanitization complexity and may not render consistently across outputs.",
                "Remove it for icon assets or explicitly allow and sanitize the required markup for a controlled illustration use case.",
            )
        external = re.search(r"(?:href|xlink:href)\s*=\s*['\"](?:https?:|//|data:)", text, re.I)
        if external:
            self.add(
                "SVG-EXTERNAL-001", "high", "high", "security", path, line_for(text, external.start()),
                external.group(0),
                "External or embedded resources can bypass asset governance, add tracking, or fail under CSP and offline use.",
                "Inline approved resources during the build or enforce an allowlist and document the network dependency.",
            )
        if re.search(r"inkscape:|sodipodi:|adobe|illustrator|sketch:type|figma", text, re.I):
            m = re.search(r"inkscape:|sodipodi:|adobe|illustrator|sketch:type|figma", text, re.I)
            self.add(
                "SVG-METADATA-001", "low", "high", "pipeline", path, line_for(text, m.start() if m else 0),
                m.group(0) if m else "Editor metadata.",
                "Editor metadata can increase payload and expose tool-specific details.",
                "Run reviewed SVG optimization that preserves required IDs, accessibility metadata, paint order, and visual fidelity.",
            )
        ids = re.findall(r"\bid\s*=\s*['\"]([^'\"]+)['\"]", text, re.I)
        duplicates = sorted(k for k, v in Counter(ids).items() if v > 1)
        if duplicates:
            self.add(
                "SVG-ID-001", "high", "high", "svg", path, 1,
                "Duplicate SVG IDs: " + ", ".join(duplicates[:10]),
                "Duplicate IDs can break gradients, masks, clipping, ARIA references, CSS, and repeated inline instances.",
                "Generate unique IDs and update every local reference. Test repeated instances on the same page.",
            )
        path_count = len(re.findall(r"<path\b", text, re.I))
        filter_count = len(re.findall(r"<(?:filter|fe[A-Za-z]+)\b", text, re.I))
        if path_count > 500 or filter_count > 80 or size > 500 * 1024:
            self.add(
                "SVG-COMPLEXITY-001", "medium", "high", "performance", path, 1,
                f"Complex SVG: {path_count} paths, {filter_count} filter nodes, {format_bytes(size)}.",
                "Complex SVG can increase parsing, style, paint, memory, and animation cost.",
                "Simplify paths and effects, rasterize texture-heavy artwork when appropriate, and profile rendering on target devices.",
            )
        if not re.search(r"<(?:title|desc)\b", text, re.I):
            self.add(
                "SVG-STANDALONE-NAME-001", "low", "medium", "accessibility", path, 1,
                "Standalone SVG file has no title or desc element.",
                "A standalone or directly opened informative SVG may lack context for users and assistive technology.",
                "Classify the file. If it can be used standalone and is informative, add contextual title/description or require naming at the host markup.",
                "Do not add redundant title elements to decorative symbols merely to silence this finding.",
            )
        if re.search(r"\b(?:fill|stroke)\s*=\s*['\"]#(?:[0-9a-f]{3,8})['\"]", text, re.I):
            m = re.search(r"\b(?:fill|stroke)\s*=\s*['\"]#(?:[0-9a-f]{3,8})['\"]", text, re.I)
            self.add(
                "SVG-COLOR-001", "medium", "medium", "theming", path, line_for(text, m.start() if m else 0),
                m.group(0) if m else "Hard-coded SVG paint.",
                "Hard-coded paint can prevent semantic theming, dark mode, state changes, and forced-colors adaptation.",
                "Classify the artwork. For interface icons, prefer currentColor or semantic custom properties. Preserve approved brand and multicolor artwork.",
                "Test light, dark, forced-colors, disabled, selected, and print states.",
            )

    def scan_text(self, path: Path) -> None:
        try:
            size = path.stat().st_size
        except OSError:
            return
        if size > self.max_text_bytes:
            self.skipped_large_text += 1
            return
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            return
        if text.startswith("# Icons and Visual Assets Audit") or (path.suffix.lower() == ".json" and "\"limitations\"" in text and "\"findings\"" in text and "\"asset_files\"" in text):
            return
        self.text_files += 1
        ext = path.suffix.lower()
        low = text.lower()

        if path.name.lower() in {"package.json", "bower.json", "composer.json", "packages.config", "pom.xml", "build.gradle", "pubspec.yaml"}:
            self.package_manifests.append(self.rel(path))
            self.scan_manifest(path, text)

        for name, pattern in ICON_LIBRARY_PATTERNS.items():
            if pattern.search(text):
                self.icon_libraries.add(name)

        if ext in HTML_LIKE:
            self.scan_html_like(path, text)
        if ext in CSS_LIKE:
            self.scan_css_like(path, text)
        if ext in JS_LIKE:
            self.scan_js_like(path, text)
        if path.name.lower() in {"manifest.json", "site.webmanifest", "manifest.webmanifest"}:
            self.scan_web_manifest(path, text)

        if "srcset=" in low:
            self.has_responsive_images = True
        if "<picture" in low:
            self.has_picture = True
        if re.search(r"<symbol\b|<use\b", text, re.I) and "svg" in low:
            self.has_svg_sprite = True

    def scan_manifest(self, path: Path, text: str) -> None:
        if path.name.lower() != "package.json":
            return
        try:
            data = json.loads(text)
        except json.JSONDecodeError:
            return
        deps = {}
        for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
            value = data.get(key, {})
            if isinstance(value, dict):
                deps.update(value)
        for dep, version in deps.items():
            for name, pattern in ICON_LIBRARY_PATTERNS.items():
                if pattern.search(dep):
                    self.icon_libraries.add(name)
            if dep in {"font-awesome", "@fortawesome/fontawesome-free"} and re.search(r"(?:^|\D)[34](?:\D|$)", str(version)):
                self.add(
                    "LIB-FONTAWESOME-LEGACY-001", "high", "medium", "legacy", path, find_line(text, dep),
                    f"Legacy Font Awesome dependency {dep}: {version}.",
                    "Legacy font-based delivery can retain unsupported APIs, private-use codepoints, and large global CSS/font payloads.",
                    "Inventory consumers and migrate through semantic aliases to a maintained SVG or reviewed current delivery model.",
                    "Verify the exact installed version and license before planning the migration.",
                )
            if dep in {"gulp-iconfont", "webfonts-generator", "fantasticon", "grunt-webfont"}:
                self.has_legacy_icon_font = True
                self.add(
                    "LIB-ICONFONT-BUILD-001", "medium", "high", "icon-font", path, find_line(text, dep),
                    f"Icon-font generation tool detected: {dep} {version}.",
                    "The build may generate a complete font and private-use mapping for interface icons.",
                    "Confirm current consumers, license rights, failure behavior, and a path toward SVG assets or a smaller compatibility subset.",
                )

    def scan_html_like(self, path: Path, text: str) -> None:
        # img checks
        for match in re.finditer(r"<img\b[^>]*>", text, re.I | re.S):
            tag = match.group(0)
            line = line_for(text, match.start())
            attrs = parse_attrs(tag)
            alt = attrs.get("alt")
            src = attrs.get("src", "")
            if alt is None:
                self.add(
                    "HTML-IMG-ALT-001", "high", "high", "accessibility", path, line, tag,
                    "An image without an alternative contract may be announced as a filename or omitted inconsistently.",
                    "Classify the image and add an appropriate alt value. Use alt=\"\" only when it is truly decorative or redundant.",
                )
            elif alt.strip() and looks_like_filename(alt.strip()):
                self.add(
                    "HTML-IMG-ALT-002", "medium", "high", "accessibility", path, line, f'alt="{alt}"',
                    "A filename-like alternative rarely communicates the image purpose in context.",
                    "Replace it with a concise contextual equivalent, or an empty alternative when the image is decorative.",
                )
            if not attrs.get("width") or not attrs.get("height"):
                self.add(
                    "HTML-IMG-DIMENSIONS-001", "medium", "high", "performance", path, line, tag,
                    "Missing intrinsic dimensions can cause layout shift while the image loads.",
                    "Provide width and height matching the source aspect ratio, or an equivalent stable aspect-ratio contract.",
                    "Verify generated templates and responsive CSS do not override the aspect ratio incorrectly.",
                )
            if attrs.get("srcset") and not attrs.get("sizes") and "w" in attrs.get("srcset", ""):
                self.add(
                    "HTML-SRCSET-SIZES-001", "medium", "high", "responsive", path, line, tag,
                    "Width-descriptor srcset has no sizes attribute, so browser selection may assume an unsuitable slot width.",
                    "Add a sizes expression that approximates the rendered slot across layouts.",
                )
            if attrs.get("srcset"):
                self.has_responsive_images = True
            if attrs.get("loading", "").lower() == "lazy" and attrs.get("fetchpriority", "").lower() == "high":
                self.add(
                    "HTML-IMG-PRIORITY-001", "high", "high", "performance", path, line, tag,
                    "The image is both lazy-loaded and marked high priority.",
                    "Conflicting hints can delay discovery while requesting priority once scheduled.",
                    "If this is critical or the likely LCP asset, remove lazy loading. Otherwise remove the high-priority hint.",
                )
            if attrs.get("loading", "").lower() == "lazy" and re.search(r"hero|banner|masthead|above[-_ ]?fold|lcp", src, re.I):
                self.add(
                    "HTML-LCP-LAZY-001", "high", "medium", "performance", path, line, tag,
                    f"Likely critical image is lazy-loaded: {src}",
                    "Lazy-loading a likely LCP asset can delay discovery and rendering.",
                    "Confirm the actual LCP element with field or lab data, then load critical imagery eagerly and size it responsively.",
                )
            if src.startswith("data:") and len(src) > 12000:
                self.add(
                    "HTML-DATAURI-001", "medium", "high", "performance", path, line, f"Inline data URI length: {len(src):,} characters.",
                    "Large data URIs duplicate bytes across HTML/CSS, reduce cache reuse, and increase document parsing cost.",
                    "Emit a hashed external asset unless critical-path measurement proves inlining is beneficial.",
                )

        for match in re.finditer(r"<picture\b[^>]*>(.*?)</picture\s*>", text, re.I | re.S):
            block = match.group(0)
            if not re.search(r"<img\b", block, re.I):
                self.add(
                    "HTML-PICTURE-FALLBACK-001", "high", "high", "responsive", path, line_for(text, match.start()), block[:240],
                    "A picture element requires an img fallback and accessible image contract.",
                    "Add an img child with src, dimensions, and contextual alternative text.",
                )
            self.has_picture = True

        # Inline SVG checks
        for match in re.finditer(r"<svg\b[^>]*>(.*?)</svg\s*>", text, re.I | re.S):
            block = match.group(0)
            open_tag = re.match(r"<svg\b[^>]*>", block, re.I | re.S)
            tag = open_tag.group(0) if open_tag else block[:200]
            attrs = parse_attrs(tag)
            line = line_for(text, match.start())
            if "viewbox" not in {k.lower() for k in attrs}:
                self.add(
                    "HTML-SVG-VIEWBOX-001", "high", "high", "svg", path, line, tag,
                    "Inline SVG without viewBox can scale inconsistently and break component sizing.",
                    "Add the intended viewBox and verify minimum, maximum, and responsive sizes.",
                )
            if re.search(r"<script\b|\bon\w+\s*=\s*['\"]|javascript\s*:", block, re.I):
                self.add(
                    "HTML-SVG-ACTIVE-001", "critical", "high", "security", path, line, block[:240],
                    "Active SVG inside page markup can execute with the page origin.",
                    "Remove active content and generate inline SVG only from trusted, sanitized, reviewed sources.",
                )
            hidden = attrs.get("aria-hidden", "").lower() == "true"
            role_img = attrs.get("role", "").lower() == "img"
            named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby") or re.search(r"<title\b[^>]*>\s*\S", block, re.I | re.S))
            if role_img and not named:
                self.add(
                    "HTML-SVG-NAME-001", "high", "high", "accessibility", path, line, tag,
                    "SVG is exposed as an image but no accessible name is evident.",
                    "Informative graphics need a contextual name; complex graphics may also need an external structured description.",
                    "Add aria-labelledby/aria-label or require the parent component to own the accessible name.",
                )
            if not hidden and not role_img and not named and looks_icon_like(block):
                self.add(
                    "HTML-SVG-SEMANTICS-001", "medium", "medium", "accessibility", path, line, tag,
                    "Icon-like inline SVG has no explicit exposed or decorative semantics.",
                    "The accessibility tree may include inconsistent or unnamed graphic nodes.",
                    "Classify the SVG. Hide decorative artwork, or expose a deliberate name and description when informative.",
                )
            for id_match in re.finditer(r"\bid\s*=\s*['\"]([^'\"]+)['\"]", block, re.I):
                self.inline_svg_id_locations[id_match.group(1)].append((self.rel(path), line_for(text, match.start() + id_match.start())))

        # icon-only interactive controls
        for pattern, kind in [
            (r"<button\b[^>]*>(.*?)</button\s*>", "button"),
            (r"<a\b[^>]*>(.*?)</a\s*>", "link"),
        ]:
            for match in re.finditer(pattern, text, re.I | re.S):
                element = match.group(0)
                inner = match.group(1)
                open_match = re.match(r"<(?:button|a)\b[^>]*>", element, re.I | re.S)
                attrs = parse_attrs(open_match.group(0) if open_match else "")
                stripped = re.sub(r"<svg\b.*?</svg\s*>|<img\b[^>]*>|<i\b.*?</i\s*>|<span\b[^>]*class=['\"][^'\"]*(?:icon|fa|glyphicon|material)[^'\"]*['\"][^>]*>.*?</span\s*>", "", inner, flags=re.I | re.S)
                visible_text = re.sub(r"<[^>]+>", " ", stripped)
                visible_text = re.sub(r"\s+", " ", visible_text).strip()
                has_icon = bool(re.search(r"<svg\b|<img\b|\b(?:fa[srldb]?|glyphicon|material-icons?|material-symbols)[-_ ]", inner, re.I))
                named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby") or attrs.get("title") or visible_text)
                if has_icon and not named:
                    self.add(
                        "HTML-ICON-CONTROL-001", "high", "high", "accessibility", path, line_for(text, match.start()), element[:240],
                        f"Icon-only {kind} has no accessible name evident from static markup.",
                        "Users of screen readers, speech input, and other assistive technologies may not know or invoke the action.",
                        "Give the interactive element a stable contextual name and keep the artwork decorative inside it.",
                    )

        # Images of text indicators
        for match in re.finditer(r"<(?:img|source)\b[^>]*(?:src|srcset)\s*=\s*['\"][^'\"]*(?:text|headline|heading|label|pricing|cta|button)[^'\"]*['\"][^>]*>", text, re.I | re.S):
            self.add(
                "HTML-IMAGE-TEXT-001", "medium", "low", "accessibility", path, line_for(text, match.start()), match.group(0),
                "The filename suggests ordinary interface text may be embedded in an image.",
                "Images of text are harder to resize, restyle, translate, search, and adapt to user preferences.",
                "Confirm the content. Use real text and CSS unless the exact visual presentation is essential, such as a logotype.",
            )

    def scan_css_like(self, path: Path, text: str) -> None:
        for match in re.finditer(r"@font-face\s*\{.*?\}", text, re.I | re.S):
            block = match.group(0)
            if re.search(r"icon|awesome|glyph|material|symbol", block, re.I) or re.search(r"unicode-range\s*:\s*U\+[EF][0-9A-F]", block, re.I):
                self.has_legacy_icon_font = True
                self.add(
                    "CSS-ICONFONT-001", "medium", "high", "icon-font", path, line_for(text, match.start()), block[:240],
                    "Icon-font face detected.",
                    "Font failure, private-use codepoints, global CSS, and whole-font payload can affect usability and maintainability.",
                    "Confirm this is a supported compatibility layer, name controls independently, and plan semantic SVG migration where practical.",
                )
        for match in re.finditer(r"content\s*:\s*['\"]\\(?:e|f)[0-9a-f]{3,5}['\"]", text, re.I):
            self.has_legacy_icon_font = True
            self.add(
                "CSS-PUA-ICON-001", "medium", "high", "icon-font", path, line_for(text, match.start()), match.group(0),
                "A pseudo-element injects a private-use glyph.",
                "Meaning is coupled to a font and may become an unknown character when styles or fonts fail.",
                "Map the selector to a semantic icon adapter and keep accessible naming on the owning element.",
            )
        for match in re.finditer(r"url\(\s*(['\"]?)data:image/[^)]{12000,}\1\s*\)", text, re.I | re.S):
            self.add(
                "CSS-DATAURI-001", "medium", "high", "performance", path, line_for(text, match.start()), f"Large CSS image data URI ({len(match.group(0)):,} characters).",
                "Large inlined images increase stylesheet parse cost, duplicate bytes, and cannot be cached independently.",
                "Emit a fingerprinted external asset unless measured critical-path benefit justifies inlining.",
            )
        positions = list(re.finditer(r"background-position\s*:", text, re.I))
        bg_images = list(re.finditer(r"background-image\s*:\s*url\([^)]*(?:sprite|icons?)[^)]*\)", text, re.I))
        if len(positions) >= 8 and bg_images:
            self.add(
                "CSS-SPRITE-001", "medium", "high", "legacy", path, line_for(text, bg_images[0].start()),
                f"Possible CSS sprite: {len(positions)} background-position declarations.",
                "Sprite coordinates couple assets to fragile CSS and complicate responsive, high-density, RTL, and theme variants.",
                "Inventory consumers and migrate through semantic aliases to SVG or generated derivatives without increasing request cost unexpectedly.",
            )
        for match in re.finditer(r"forced-color-adjust\s*:\s*none", text, re.I):
            self.add(
                "CSS-FORCED-COLORS-001", "high", "high", "accessibility", path, line_for(text, match.start()), match.group(0),
                "The element opts out of user-agent forced-color adjustment.",
                "Icons or illustrations may become invisible or preserve low-contrast brand colors against user preferences.",
                "Remove the opt-out or provide a complete system-color treatment and test actual forced-colors modes.",
            )
        for match in re.finditer(r"(?:fill|stroke)\s*:\s*#[0-9a-f]{3,8}", text, re.I):
            self.add(
                "CSS-SVG-COLOR-001", "medium", "medium", "theming", path, line_for(text, match.start()), match.group(0),
                "Hard-coded SVG paint in CSS may bypass semantic color roles.",
                "Interface icons can fail dark mode, states, or forced-colors adaptation.",
                "Use currentColor or semantic custom properties when the artwork is not an approved fixed-color brand or illustration asset.",
            )
        rtl_rule = re.search(r"(?:\[dir\s*=\s*['\"]?rtl|:dir\(rtl\)).{0,240}(?:\.icon|svg|i)\b.{0,160}transform\s*:\s*(?:scaleX\(\s*-1\s*\)|rotateY\(\s*180deg\s*\))", text, re.I | re.S)
        if rtl_rule:
            self.add(
                "CSS-RTL-MIRROR-001", "medium", "medium", "rtl", path, line_for(text, rtl_rule.start()), rtl_rule.group(0),
                "Brand, media, chart, clock, map, and culturally fixed symbols may be mirrored incorrectly.",
                "Mirror semantic directional aliases selectively rather than every icon or SVG.",
                "Review each affected semantic icon with localization and design owners.",
            )
        for match in re.finditer(r"(?:background|background-image)\s*:\s*(?:[^;]*?)url\([^)]*\)", text, re.I):
            window = text[max(0, match.start() - 160):match.start()]
            if re.search(r"::?(?:before|after)|\.icon|\.status|\.error|\.warning", window, re.I):
                self.add(
                    "CSS-BACKGROUND-CONTENT-001", "medium", "low", "accessibility", path, line_for(text, match.start()), match.group(0),
                    "A background image may be carrying icon or status meaning.",
                    "CSS backgrounds are not exposed as content and disappear when styles or images fail.",
                    "Confirm it is decorative. Move essential information to semantic markup with an equivalent text or control name.",
                )

    def scan_js_like(self, path: Path, text: str) -> None:
        for match in re.finditer(r"(?:innerHTML|outerHTML|insertAdjacentHTML|dangerouslySetInnerHTML|v-html|{@html})", text, re.I):
            window = text[max(0, match.start() - 180):match.end() + 220]
            if re.search(r"svg|icon|image/svg\+xml|\.svg", window, re.I):
                self.add(
                    "JS-SVG-INJECTION-001", "critical", "medium", "security", path, line_for(text, match.start()), window,
                    "Untrusted SVG markup can execute active content or manipulate page structure when injected inline.",
                    "Use trusted generated components or a strict allowlist sanitizer. Do not pass user-controlled SVG to raw HTML APIs.",
                    "Trace data origin and every sanitizer or transform in the path.",
                )
        for match in re.finditer(r"import\s+\*\s+as\s+\w+\s+from\s+['\"](?:[^'\"]*(?:icons|heroicons|lucide|phosphor|tabler)[^'\"]*)['\"]", text, re.I):
            self.add(
                "JS-ICON-NAMESPACE-001", "medium", "medium", "performance", path, line_for(text, match.start()), match.group(0),
                "Large icon namespaces can increase parse, tree-shaking risk, and accidental library coupling.",
                "Import approved icons explicitly or generate a semantic registry that preserves effective tree shaking.",
                "Inspect the production bundle before concluding that every icon is shipped.",
            )
        for match in re.finditer(r"library\.add\(\s*(?:fas|far|fab|fal|fad|fass|fasr|fat)\s*\)", text, re.I):
            self.add(
                "JS-FONTAWESOME-ALL-001", "high", "high", "performance", path, line_for(text, match.start()), match.group(0),
                "A full Font Awesome style pack is registered.",
                "Shipping a complete pack can add substantial unused icon data.",
                "Register only approved icons or use build-time semantic imports, subject to the exact license and package contract.",
            )
        for match in re.finditer(r"DOMParser\s*\(\s*\).*?parseFromString\s*\([^,]+,\s*['\"]image/svg\+xml['\"]", text, re.I | re.S):
            self.add(
                "JS-SVG-PARSER-001", "high", "medium", "security", path, line_for(text, match.start()), match.group(0),
                "SVG text is parsed into a document at runtime.",
                "Parsing is not sanitization; active content and external references may remain dangerous when imported or serialized.",
                "Accept only trusted generated SVG or sanitize against a strict allowlist before insertion.",
            )

    def scan_web_manifest(self, path: Path, text: str) -> None:
        try:
            data = json.loads(text)
        except json.JSONDecodeError:
            self.add(
                "MANIFEST-JSON-001", "high", "high", "app-icons", path, 1,
                "Web app manifest is not valid JSON.",
                "Browsers may ignore app icon and installation metadata.",
                "Fix JSON syntax and validate the manifest in target browsers.",
            )
            return
        icons = data.get("icons", [])
        if not isinstance(icons, list) or not icons:
            self.add(
                "MANIFEST-ICONS-001", "medium", "high", "app-icons", path, 1,
                "Web app manifest has no icons array.",
                "Installed experiences may use low-quality fallbacks or omit expected branding.",
                "Provide reviewed icon sizes, formats, and purposes required by target platforms.",
            )
            return
        maskable = False
        for index, icon in enumerate(icons):
            if not isinstance(icon, dict):
                continue
            if not icon.get("src") or not icon.get("sizes"):
                self.add(
                    "MANIFEST-ICON-META-001", "medium", "high", "app-icons", path, 1,
                    f"Manifest icon entry {index} lacks src or sizes: {icon}",
                    "Browsers may select an unsuitable icon or ignore the entry.",
                    "Declare source, sizes, type where useful, and purpose according to actual generated assets.",
                )
            if "maskable" in str(icon.get("purpose", "")):
                maskable = True
        if not maskable:
            self.add(
                "MANIFEST-MASKABLE-001", "low", "medium", "app-icons", path, 1,
                "No maskable app icon is declared.",
                "Some installed surfaces may crop a generic icon poorly.",
                "Evaluate a maskable-safe variant with protected content area for supported install targets.",
            )

    def post_process(self) -> None:
        for digest, records in self.asset_hashes.items():
            if len(records) > 1:
                paths = [r.path for r in records]
                first = self.root / paths[0]
                self.add(
                    "ASSET-DUPLICATE-001", "medium", "high", "governance", first, 1,
                    "Exact duplicate assets: " + ", ".join(paths[:8]),
                    "Duplicated files create divergent ownership, updates, and cache keys.",
                    "Choose one source of truth or generate aliases through the manifest. Confirm duplicates are not required by packaging boundaries.",
                )

        for svg_id, locations in self.inline_svg_id_locations.items():
            if len(locations) > 1:
                path_str, line = locations[0]
                self.add(
                    "HTML-SVG-ID-COLLISION-001", "high", "medium", "svg", self.root / path_str, line,
                    f"Inline SVG ID '{svg_id}' appears {len(locations)} times across scanned templates.",
                    "Repeated inline IDs can redirect gradients, masks, clip paths, CSS, and ARIA references to the wrong instance.",
                    "Generate per-instance IDs or use a build strategy that safely scopes references.",
                    "Template branches may not render together; confirm actual page composition.",
                )

        if len(self.icon_libraries) >= 3:
            self.add(
                "LIB-MULTIPLE-001", "medium", "high", "governance", self.root, 1,
                "Multiple icon systems detected: " + ", ".join(sorted(self.icon_libraries)),
                "Concurrent icon systems increase payload, visual inconsistency, licensing obligations, and migration complexity.",
                "Define a primary semantic icon contract, document approved exceptions, and retire duplicate libraries incrementally.",
            )

        if self.icon_libraries and not self.has_license_file:
            self.add(
                "LICENSE-NOTICE-001", "high", "medium", "licensing", self.root, 1,
                "External icon libraries were detected, but no repository-level license or third-party notice file was found.",
                "Required attribution, notices, artifact-specific licenses, or trademark restrictions may be lost during distribution.",
                "Create a verified third-party asset register and include exact license texts and obligations for shipped artifacts.",
                "A monorepo may store notices outside the scanned root; verify the actual distribution package.",
            )

        if self.assets and not self.has_asset_manifest:
            self.add(
                "GOV-ASSET-MANIFEST-001", "low", "medium", "governance", self.root, 1,
                "Visual assets were found but no obvious asset inventory or provenance manifest was detected.",
                "Ownership, source, license, theme variants, and retirement status can become difficult to govern.",
                "Maintain a generated or curated asset catalog with semantic name, source, license, consumers, and status.",
            )

        raster = [a for a in self.assets if a.extension in {".png", ".jpg", ".jpeg", ".gif"}]
        if len(raster) >= 8 and not self.has_modern_raster and not self.has_responsive_images:
            self.add(
                "PIPELINE-RESPONSIVE-001", "medium", "medium", "performance", self.root, 1,
                f"Found {len(raster)} legacy raster assets with no modern raster files or responsive image markup detected.",
                "The project may deliver a single oversized format to every viewport and density.",
                "Measure actual slots and generate responsive candidates and modern encodings where quality and support justify them.",
            )

        if self.assets and not self.has_visual_tests:
            self.add(
                "TEST-VISUAL-001", "low", "medium", "testing", self.root, 1,
                "No obvious visual regression or screenshot test configuration was detected.",
                "Asset optimization, icon swaps, and theme changes can regress alignment, cropping, contrast, and generated output.",
                "Add representative visual and accessibility tests for stable component states and critical compositions.",
            )

    def report(self) -> dict:
        counts = Counter(f.severity for f in self.findings)
        ext_counts = Counter(a.extension for a in self.assets)
        total_bytes = sum(a.bytes for a in self.assets)
        return {
            "root": str(self.root),
            "summary": {
                "files_scanned": self.files_scanned,
                "text_files_scanned": self.text_files,
                "large_text_files_skipped": self.skipped_large_text,
                "asset_files": len(self.assets),
                "asset_bytes": total_bytes,
                "asset_bytes_human": format_bytes(total_bytes),
                "findings": len(self.findings),
                "severity": {k: counts.get(k, 0) for k in ("critical", "high", "medium", "low", "info")},
                "icon_libraries": sorted(self.icon_libraries),
                "asset_extensions": dict(sorted(ext_counts.items())),
                "signals": {
                    "responsive_images": self.has_responsive_images,
                    "picture": self.has_picture,
                    "svg_sprite": self.has_svg_sprite,
                    "modern_raster": self.has_modern_raster,
                    "legacy_icon_font": self.has_legacy_icon_font,
                    "license_notice": self.has_license_file,
                    "asset_manifest": self.has_asset_manifest,
                    "visual_tests": self.has_visual_tests,
                },
            },
            "assets": [asdict(a) for a in self.assets],
            "findings": [asdict(f) for f in self.findings],
            "limitations": [
                "Static analysis cannot determine the contextual purpose of every image or icon.",
                "The scanner does not certify WCAG conformance, perceptual image quality, safe SVG sanitization, or license compliance.",
                "Generated files, runtime DOM, CDN headers, bundles, and routes excluded from the scanned root require separate inspection.",
                "Raster dimensions are read for common PNG, JPEG, and GIF files only.",
            ],
        }


def parse_attrs(tag: str) -> dict[str, str]:
    attrs: dict[str, str] = {}
    for match in re.finditer(r"([:\w-]+)(?:\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\s>]+)))?", tag):
        key = match.group(1).lower()
        if key.startswith("<") or key in {"img", "svg", "button", "a", "source", "picture"}:
            continue
        value = match.group(2) if match.group(2) is not None else match.group(3) if match.group(3) is not None else match.group(4) if match.group(4) is not None else ""
        attrs[key] = value
    return attrs


def compact(value: str, limit: int = 200) -> str:
    value = re.sub(r"\s+", " ", value).strip()
    return value if len(value) <= limit else value[: limit - 1] + "…"


def line_for(text: str, position: int) -> int:
    return text.count("\n", 0, max(0, position)) + 1


def find_line(text: str, token: str) -> int:
    index = text.find(token)
    return line_for(text, index if index >= 0 else 0)


def looks_like_filename(value: str) -> bool:
    return bool(re.fullmatch(r"(?:[^/\\]+[/\\])*[^/\\]+\.(?:png|jpe?g|gif|webp|avif|svg|ico)", value, re.I))


def looks_icon_like(svg_block: str) -> bool:
    if len(svg_block) > 8000:
        return False
    shapes = len(re.findall(r"<(?:path|circle|rect|line|polyline|polygon|ellipse)\b", svg_block, re.I))
    text_nodes = bool(re.search(r"<text\b", svg_block, re.I))
    return shapes > 0 and shapes < 40 and not text_nodes


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def read_image_dimensions(path: Path) -> tuple[Optional[int], Optional[int]]:
    ext = path.suffix.lower()
    try:
        with path.open("rb") as f:
            if ext == ".png":
                signature = f.read(24)
                if len(signature) >= 24 and signature[:8] == b"\x89PNG\r\n\x1a\n":
                    return struct.unpack(">II", signature[16:24])
            elif ext in {".jpg", ".jpeg"}:
                if f.read(2) != b"\xff\xd8":
                    return None, None
                while True:
                    marker_start = f.read(1)
                    if not marker_start:
                        break
                    if marker_start != b"\xff":
                        continue
                    marker = f.read(1)
                    while marker == b"\xff":
                        marker = f.read(1)
                    if marker in {b"\xd8", b"\xd9"}:
                        continue
                    length_data = f.read(2)
                    if len(length_data) != 2:
                        break
                    length = struct.unpack(">H", length_data)[0]
                    if marker and marker[0] in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
                        data = f.read(5)
                        if len(data) == 5:
                            height, width = struct.unpack(">HH", data[1:5])
                            return width, height
                        break
                    f.seek(max(0, length - 2), 1)
            elif ext == ".gif":
                header = f.read(10)
                if len(header) == 10 and header[:6] in {b"GIF87a", b"GIF89a"}:
                    width, height = struct.unpack("<HH", header[6:10])
                    return width, height
    except (OSError, struct.error):
        return None, None
    return None, None


def format_bytes(value: int) -> str:
    units = ["B", "KB", "MB", "GB"]
    amount = float(value)
    for unit in units:
        if amount < 1024 or unit == units[-1]:
            return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B"
        amount /= 1024
    return f"{value} B"


def render_markdown(report: dict) -> str:
    summary = report["summary"]
    sev = summary["severity"]
    lines = [
        "# Icons and Visual Assets Audit",
        "",
        f"- Root: `{report['root']}`",
        f"- Files scanned: {summary['files_scanned']}",
        f"- Visual assets: {summary['asset_files']} ({summary['asset_bytes_human']})",
        f"- Findings: {summary['findings']}",
        f"- Severity: Critical {sev['critical']}, High {sev['high']}, Medium {sev['medium']}, Low {sev['low']}, Info {sev['info']}",
        f"- Icon libraries: {', '.join(summary['icon_libraries']) if summary['icon_libraries'] else 'none detected'}",
        "",
        "## Architecture signals",
        "",
    ]
    for key, value in summary["signals"].items():
        lines.append(f"- {key.replace('_', ' ').title()}: {'yes' if value else 'no'}")
    lines.extend(["", "## Findings", ""])
    if not report["findings"]:
        lines.append("No findings were produced by the static checks.")
    for finding in report["findings"]:
        location = finding["file"]
        if finding["line"]:
            location += f":{finding['line']}"
        lines.extend([
            f"### [{finding['severity'].upper()}] {finding['rule_id']} — {finding['area']}",
            "",
            f"- Location: `{location}`",
            f"- Confidence: {finding['confidence']}",
            f"- Evidence: {finding['evidence']}",
            f"- Impact: {finding['impact']}",
            f"- Remediation: {finding['remediation']}",
        ])
        if finding.get("manual_check"):
            lines.append(f"- Manual check: {finding['manual_check']}")
        lines.append("")
    lines.extend(["## Limitations", ""])
    lines.extend(f"- {item}" for item in report["limitations"])
    return "\n".join(lines).rstrip() + "\n"


def threshold_failed(findings: Iterable[dict], threshold: str) -> bool:
    minimum = SEVERITY_ORDER[threshold]
    return any(SEVERITY_ORDER.get(item["severity"], 0) >= minimum for item in findings)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Inspect icon and visual asset usage in a repository.")
    parser.add_argument("path", type=Path, help="Project directory to scan")
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--output", type=Path, help="Write report to this file instead of stdout")
    parser.add_argument("--fail-on", choices=("critical", "high", "medium", "low", "info"), help="Exit 2 when a finding at or above this severity exists")
    parser.add_argument("--max-text-bytes", type=int, default=4 * 1024 * 1024, help="Skip text files larger than this size")
    parser.add_argument("--max-files", type=int, default=50000, help="Maximum files to inspect")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    if not args.path.exists() or not args.path.is_dir():
        print(f"error: not a directory: {args.path}", file=sys.stderr)
        return 1
    scanner = Scanner(args.path, max(1024, args.max_text_bytes), max(1, args.max_files))
    report = scanner.run()
    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)
    if args.fail_on and threshold_failed(report["findings"], args.fail_on):
        return 2
    return 0


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