#!/usr/bin/env python3
"""Static inspector for semantic tables and interactive data grids.

The inspector intentionally reports evidence and heuristics. It cannot prove
runtime accessibility, data correctness, authorization, or WCAG conformance.
"""

from __future__ import annotations

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

TEXT_EXTENSIONS = {
    ".html", ".htm", ".xhtml", ".css", ".scss", ".sass", ".less",
    ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".php", ".cfm",
    ".cfml", ".jsp", ".jspx", ".aspx", ".ascx", ".cshtml", ".vbhtml",
    ".java", ".cs", ".vb", ".py", ".rb", ".json", ".json5", ".xml",
}
SKIP_DIRS = {
    ".git", ".svn", ".hg", "node_modules", "vendor", "dist", "build",
    "coverage", ".next", ".nuxt", ".cache", "target", "bin", "obj",
}
MAX_FILE_SIZE = 2_500_000
SEVERITY_ORDER = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}


@dataclass(frozen=True)
class Finding:
    rule_id: str
    severity: str
    confidence: str
    area: str
    file: str
    line: int
    evidence: str
    impact: str
    remediation: str
    manual_check: str


def iter_files(root: Path) -> Iterable[Path]:
    if root.is_file():
        yield root
        return
    for current, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
        for name in files:
            path = Path(current) / name
            if path.suffix.lower() in TEXT_EXTENSIONS and path.stat().st_size <= MAX_FILE_SIZE:
                yield path


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8", errors="ignore")


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


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


def attr_value(attrs: str, name: str) -> str | None:
    pattern = rf"\b{re.escape(name)}\s*=\s*([\"'])(.*?)\1"
    match = re.search(pattern, attrs, re.I | re.S)
    return match.group(2) if match else None


def has_attr(attrs: str, name: str) -> bool:
    return bool(re.search(rf"\b{re.escape(name)}(?:\s*=|\s|$)", attrs, re.I))


class Inspector:
    def __init__(self, root: Path):
        self.root = root.resolve()
        self.findings: list[Finding] = []
        self._keys: set[tuple[str, str, int, str]] = set()
        self.stack: Counter[str] = Counter()
        self.files_scanned = 0
        self.table_count = 0
        self.grid_count = 0
        self.package_versions: dict[str, str] = {}

    def relative(self, path: Path) -> str:
        try:
            return str(path.resolve().relative_to(self.root)) if self.root.is_dir() else path.name
        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:
        rel = self.relative(path)
        evidence = compact(evidence)
        key = (rule_id, rel, line, evidence)
        if key in self._keys:
            return
        self._keys.add(key)
        self.findings.append(
            Finding(
                rule_id=rule_id,
                severity=severity,
                confidence=confidence,
                area=area,
                file=rel,
                line=max(1, line),
                evidence=evidence,
                impact=impact,
                remediation=remediation,
                manual_check=manual_check,
            )
        )

    def inspect(self) -> None:
        paths = list(iter_files(self.root))
        contents: dict[Path, str] = {}
        for path in paths:
            try:
                text = read_text(path)
            except OSError:
                continue
            contents[path] = text
            self.files_scanned += 1
            if path.name == "package.json":
                self.inspect_package_json(path, text)

        for path, text in contents.items():
            suffix = path.suffix.lower()
            self.detect_stack(text)
            if suffix in {".html", ".htm", ".xhtml", ".php", ".cfm", ".cfml", ".jsp", ".jspx", ".aspx", ".ascx", ".cshtml", ".vbhtml", ".vue", ".svelte", ".jsx", ".tsx"}:
                self.inspect_markup(path, text)
            if suffix in {".css", ".scss", ".sass", ".less", ".vue", ".svelte", ".html", ".htm", ".php", ".cfm", ".cfml", ".jsp", ".aspx", ".cshtml"}:
                self.inspect_css(path, text)
            if suffix in {".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".html", ".htm", ".php", ".cfm", ".cfml", ".jsp", ".aspx", ".cshtml"}:
                self.inspect_client_code(path, text)
            if suffix in {".php", ".cfm", ".cfml", ".jsp", ".jspx", ".java", ".cs", ".vb", ".py", ".rb", ".js", ".ts"}:
                self.inspect_server_code(path, text)

        self.add_cross_project_findings(contents)
        self.findings.sort(key=lambda f: (-SEVERITY_ORDER[f.severity], f.file, f.line, f.rule_id))

    def inspect_package_json(self, path: Path, text: str) -> None:
        try:
            data = json.loads(text)
        except json.JSONDecodeError:
            return
        deps: dict[str, str] = {}
        for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
            value = data.get(key)
            if isinstance(value, dict):
                deps.update({str(k): str(v) for k, v in value.items()})
        watched = {
            "datatables.net": "DataTables",
            "datatables.net-bs": "DataTables Bootstrap",
            "datatables.net-bs4": "DataTables Bootstrap 4",
            "datatables.net-bs5": "DataTables Bootstrap 5",
            "ag-grid-community": "AG Grid",
            "ag-grid-enterprise": "AG Grid Enterprise",
            "@tanstack/react-table": "TanStack Table",
            "@tanstack/vue-table": "TanStack Table",
            "@tanstack/angular-table": "TanStack Table",
            "@tanstack/table-core": "TanStack Table",
            "handsontable": "Handsontable",
            "tabulator-tables": "Tabulator",
            "bootstrap-table": "Bootstrap Table",
            "slickgrid": "SlickGrid",
            "jquery": "jQuery",
            "bootstrap": "Bootstrap",
        }
        for package, label in watched.items():
            if package in deps:
                self.stack[label] += 1
                self.package_versions[package] = deps[package]

    def detect_stack(self, text: str) -> None:
        patterns = {
            "DataTables": r"(?:new\s+DataTable\s*\(|\.DataTable\s*\(|\.dataTable\s*\(|datatables\.net|jquery\.dataTables)",
            "AG Grid": r"(?:ag-grid|createGrid\s*\(|AgGridReact|AgGridVue|agGrid\.Grid)",
            "TanStack Table": r"(?:@tanstack/(?:react|vue|angular|table-core)-table|useReactTable\s*\(|useVueTable\s*\()",
            "Handsontable": r"(?:handsontable|new\s+Handsontable\s*\()",
            "Tabulator": r"(?:tabulator-tables|new\s+Tabulator\s*\()",
            "Bootstrap Table": r"(?:bootstrap-table|\.bootstrapTable\s*\()",
            "jQuery": r"(?:jquery(?:\.min)?\.js|\$\s*\(|jQuery\s*\()",
            "Bootstrap": r"(?:bootstrap(?:\.bundle)?(?:\.min)?\.(?:css|js)|class=[\"'][^\"']*\btable(?:-responsive|-striped|-hover)?\b)",
        }
        for label, pattern in patterns.items():
            if re.search(pattern, text, re.I):
                self.stack[label] += 1

    def inspect_markup(self, path: Path, text: str) -> None:
        table_pattern = re.compile(r"<table\b(?P<attrs>[^>]*)>(?P<body>.*?)</table\s*>", re.I | re.S)
        for table_match in table_pattern.finditer(text):
            self.table_count += 1
            attrs = table_match.group("attrs")
            body = table_match.group("body")
            start = table_match.start()
            line = line_number(text, start)
            role = (attr_value(attrs, "role") or "").lower()
            presentational = role in {"presentation", "none"}
            rows = len(re.findall(r"<tr\b", body, re.I))
            cells = len(re.findall(r"<t[dh]\b", body, re.I))
            headers = list(re.finditer(r"<th\b(?P<attrs>[^>]*)>(?P<body>.*?)</th\s*>", body, re.I | re.S))

            if not presentational and rows >= 1 and cells >= 2 and not re.search(r"<caption\b", body, re.I):
                self.add(
                    "DT-A11Y-001", "medium", "high", "accessibility", path, line,
                    table_match.group(0)[:180],
                    "The table may be difficult to identify or distinguish when navigating by table with assistive technology.",
                    "Add a concise <caption>, or associate a visible heading through a tested accessible naming strategy.",
                    "Confirm whether nearby context already names the table clearly and test table navigation with supported screen readers.",
                )

            if not presentational and rows > 1 and cells > 2 and not headers:
                self.add(
                    "DT-A11Y-002", "high", "high", "accessibility", path, line,
                    table_match.group(0)[:180],
                    "Data cells have no programmatic row or column headers, so relationships may be lost to assistive technology.",
                    "Use <th> cells with scope for simple tables, or explicit headers/id associations for irregular tables.",
                    "Verify that this is a data table rather than a layout table.",
                )

            if headers:
                without_scope = [h for h in headers if not has_attr(h.group("attrs"), "scope") and not has_attr(h.group("attrs"), "headers")]
                has_row_headers = bool(re.search(r"<tr\b[^>]*>\s*<th\b", body, re.I | re.S)) and len(headers) > 1
                has_col_headers = bool(re.search(r"<thead\b|<tr\b[^>]*>.*?<th\b", body, re.I | re.S))
                if without_scope and (has_row_headers or rows > 5 or len(headers) > 3):
                    self.add(
                        "DT-A11Y-003", "medium", "medium", "accessibility", path,
                        line_number(text, table_match.start("body") + without_scope[0].start()),
                        without_scope[0].group(0),
                        "Header direction may be ambiguous in larger or two-dimensional tables.",
                        "Add scope=\"col\" or scope=\"row\" for simple relationships; use headers/id for irregular structures.",
                        "Inspect the complete header model, including colspan and rowspan, with a screen reader.",
                    )

            if re.search(r"<table\b", body, re.I):
                self.add(
                    "DT-STRUCT-001", "medium", "high", "semantics", path, line,
                    "Nested <table> detected",
                    "Nested tables are difficult to navigate and often indicate mixed record structures or layout markup.",
                    "Split distinct datasets into separate named tables or move row details outside the parent table structure.",
                    "Confirm whether the nested table is truly tabular data and test header announcements.",
                )

            if rows > 100:
                self.add(
                    "DT-PERF-001", "medium", "high", "performance", path, line,
                    f"Static table contains approximately {rows} rows",
                    "A large initial DOM can delay rendering, increase memory, and create a long navigation sequence.",
                    "Measure with representative hardware and consider pagination, progressive loading, or a justified virtualization strategy.",
                    "Check actual payload size, cell complexity, user task, browser memory, and assistive-technology behavior before changing architecture.",
                )

            width = attr_value(attrs, "width")
            if width and re.fullmatch(r"\d+", width) and int(width) >= 800:
                self.add(
                    "DT-RESP-001", "medium", "high", "responsive", path, line,
                    f"<table width=\"{width}\">",
                    "A fixed presentational width can prevent reflow and create unnecessary overflow at zoom or narrow viewports.",
                    "Remove the fixed HTML width and implement content-driven responsive behavior in CSS.",
                    "Verify that horizontal scrolling remains available when comparison requires full column width.",
                )

            for th in headers:
                th_attrs = th.group("attrs")
                th_body = th.group("body")
                looks_sortable = bool(re.search(r"(?:onclick|data-sort|sortable|sorting|orderable)", th_attrs + th_body, re.I))
                if looks_sortable and not re.search(r"<(?:button|a)\b", th_body, re.I):
                    self.add(
                        "DT-A11Y-004", "high", "medium", "accessibility", path,
                        line_number(text, table_match.start("body") + th.start()),
                        th.group(0),
                        "A sortable header may not be keyboard-operable or expose a clear control name and state.",
                        "Place a real button inside the <th>, keep aria-sort on the active header cell, and expose current direction visually and programmatically.",
                        "Test focus, Enter/Space activation, multi-sort precedence, and post-sort focus behavior.",
                    )

            repeated_generic_actions = len(re.findall(r">\s*(?:edit|delete|open|view|actions?)\s*</(?:a|button)>", body, re.I))
            if repeated_generic_actions >= 2:
                self.add(
                    "DT-A11Y-005", "medium", "medium", "accessibility", path, line,
                    f"{repeated_generic_actions} repeated generic row action labels",
                    "Repeated controls such as “Edit” or “Delete” may be ambiguous outside visual row context.",
                    "Include the record identity in each accessible name while keeping the visible label concise.",
                    "Inspect the computed accessible names in the accessibility tree.",
                )

            row_checkboxes = list(re.finditer(r"<input\b(?=[^>]*\btype\s*=\s*['\"]checkbox['\"])(?P<attrs>[^>]*)>", body, re.I))
            unnamed = [m for m in row_checkboxes if not any(has_attr(m.group("attrs"), name) for name in ("aria-label", "aria-labelledby", "title", "id"))]
            if len(unnamed) >= 2:
                self.add(
                    "DT-A11Y-006", "high", "medium", "accessibility", path,
                    line_number(text, table_match.start("body") + unnamed[0].start()),
                    unnamed[0].group(0),
                    "Row-selection checkboxes may not expose which record they select.",
                    "Give each checkbox a persistent label or an accessible name containing row context; define select-all scope explicitly.",
                    "Confirm whether external <label for> elements provide names before changing markup.",
                )

        for grid in re.finditer(r"<(?P<tag>[a-z0-9:-]+)\b(?P<attrs>[^>]*\brole\s*=\s*['\"]grid['\"][^>]*)>", text, re.I):
            self.grid_count += 1
            attrs = grid.group("attrs")
            line = line_number(text, grid.start())
            if not has_attr(attrs, "aria-label") and not has_attr(attrs, "aria-labelledby"):
                self.add(
                    "DT-GRID-001", "medium", "high", "accessibility", path, line,
                    grid.group(0),
                    "The grid may not have a programmatic name describing its purpose.",
                    "Associate the grid with a visible heading using aria-labelledby or provide a concise aria-label.",
                    "Inspect the computed accessible name and avoid duplicating nearby text.",
                )
            if grid.group("tag").lower() != "table" and not re.search(r"\brole\s*=\s*['\"](?:row|gridcell|columnheader|rowheader)['\"]", text, re.I):
                self.add(
                    "DT-GRID-002", "high", "high", "accessibility", path, line,
                    grid.group(0),
                    "A non-table ARIA grid was detected without clear row/cell descendants.",
                    "Implement the complete grid structure and managed-focus keyboard model, or use a native table instead.",
                    "Inspect runtime-rendered descendants because framework templates may create them dynamically.",
                )
            if not re.search(r"\btabindex\s*=|onKeyDown|keydown|addEventListener\s*\(\s*['\"]keydown", text, re.I):
                self.add(
                    "DT-GRID-003", "high", "medium", "accessibility", path, line,
                    grid.group(0),
                    "No static evidence of a managed focus or keyboard model was found for the ARIA grid.",
                    "Implement roving focus or an equivalent grid navigation model, including editing entry/exit and focus restoration.",
                    "Verify behavior at runtime; keyboard logic may be imported from another file or library.",
                )

        for positive in re.finditer(r"\btabindex\s*=\s*['\"]([1-9]\d*)['\"]", text, re.I):
            self.add(
                "DT-A11Y-007", "high", "high", "accessibility", path, line_number(text, positive.start()),
                positive.group(0),
                "Positive tabindex creates a separate focus order that can diverge from DOM and visual order.",
                "Use DOM order and tabindex=\"0\"/-1 with a documented roving-focus model where needed.",
                "Test the complete page, not only the table, with keyboard navigation.",
            )

    def inspect_css(self, path: Path, text: str) -> None:
        rule_pattern = re.compile(r"(?P<selector>[^{}]+)\{(?P<body>[^{}]*)\}", re.S)
        for match in rule_pattern.finditer(text):
            selector = compact(match.group("selector"), 160)
            body = match.group("body")
            line = line_number(text, match.start())
            table_selector = bool(re.search(r"(?:^|[\s>,+~])(?:table|thead|tbody|tfoot|tr|th|td)(?:\b|[:.#\[])" , selector, re.I))

            if table_selector and re.search(r"\bdisplay\s*:\s*(?:block|flex|grid|contents)\b", body, re.I):
                self.add(
                    "DT-CSS-001", "medium", "medium", "semantics", path, line,
                    f"{selector} {{ {compact(body, 140)} }}",
                    "Changing native table display roles can alter layout, header relationships, or accessibility behavior across browsers.",
                    "Prefer preserving table display semantics; use an overflow wrapper or an explicitly tested alternate view.",
                    "Inspect computed accessibility semantics in supported browsers because impact depends on selector and engine.",
                )

            if re.search(r"(?:nth-child|nth-of-type)", selector, re.I) and re.search(r"\bdisplay\s*:\s*none\b", body, re.I):
                self.add(
                    "DT-RESP-002", "high", "high", "responsive", path, line,
                    f"{selector} {{ {compact(body, 140)} }}",
                    "Columns may disappear by visual position, causing data loss and fragile behavior when columns change.",
                    "Hide only explicitly classified optional columns and expose their data through a reachable details or alternate view.",
                    "Verify that hidden headers and cells stay aligned and that essential data is never removed.",
                )

            if table_selector:
                width_values = [int(v) for v in re.findall(r"\b(?:min-)?width\s*:\s*(\d{3,5})px", body, re.I)]
                if any(v >= 800 for v in width_values):
                    self.add(
                        "DT-RESP-003", "medium", "high", "responsive", path, line,
                        f"{selector} {{ {compact(body, 140)} }}",
                        "A large fixed table width may fail narrow containers, split view, browser zoom, or text enlargement.",
                        "Use an overflow region and content-driven sizing; keep fixed minimums only where the comparison task requires them.",
                        "Test at 320 CSS px, 400% zoom, long translations, and RTL.",
                    )

            if table_selector and re.search(r"white-space\s*:\s*nowrap", body, re.I) and re.search(r"(?:overflow\s*:\s*hidden|text-overflow\s*:\s*ellipsis)", body, re.I):
                self.add(
                    "DT-CONTENT-001", "medium", "high", "content", path, line,
                    f"{selector} {{ {compact(body, 140)} }}",
                    "Cell content may be truncated without an accessible or operable way to reveal the full value.",
                    "Allow wrapping where possible or provide a keyboard- and touch-accessible details mechanism with the full text.",
                    "Check copied, printed, exported, zoomed, and screen-reader representations.",
                )

            if re.search(r":focus(?:-visible)?", selector, re.I) and re.search(r"outline\s*:\s*(?:0|none)", body, re.I) and not re.search(r"(?:box-shadow|border(?:-color)?|outline-offset)\s*:", body, re.I):
                self.add(
                    "DT-A11Y-008", "high", "high", "accessibility", path, line,
                    f"{selector} {{ {compact(body, 140)} }}",
                    "Keyboard focus may become invisible on table controls or grid cells.",
                    "Provide a visible high-contrast focus indicator that is not clipped by overflow or sticky layers.",
                    "Test forced colors, dark mode, zoom, sticky headers, and horizontally scrolled cells.",
                )

    def inspect_client_code(self, path: Path, text: str) -> None:
        legacy_dt_patterns = {
            r"\bfn(?:Draw|Filter|GetData|GetNodes|SetColumnVis|Settings|PageChange|Destroy|Update|AddData|DeleteRow)\s*\(": "Legacy DataTables fn* API",
            r"\b(?:bServerSide|sAjaxSource|aoColumns|aaData|mDataProp|iDisplayLength|oLanguage|bPaginate|bFilter|bSort|sDom|sPaginationType)\s*:": "DataTables Hungarian-notation option",
        }
        for pattern, label in legacy_dt_patterns.items():
            for match in re.finditer(pattern, text, re.I):
                self.add(
                    "DT-MIG-001", "high", "high", "migration", path, line_number(text, match.start()),
                    f"{label}: {match.group(0)}",
                    "The construct is removed or legacy in modern DataTables and can block a safe DataTables 2 migration.",
                    "Replace it with the current chainable API or modern option in a separate tested patch before upgrading the major version.",
                    "Identify the exact installed core and extension versions before changing code.",
                )

        for match in re.finditer(r"(?:jquery\.dataTables(?:\.min)?\.js|jquery\.dataTables(?:\.min)?\.css)", text, re.I):
            self.add(
                "DT-MIG-002", "medium", "high", "migration", path, line_number(text, match.start()),
                match.group(0),
                "The legacy DataTables distribution filename may require updating during a DataTables 2 migration.",
                "Use the distribution and styling files documented for the installed DataTables generation and update bundled assets together.",
                "Check whether a download-builder bundle or package manager resolves filenames automatically.",
            )

        for match in re.finditer(r"\bdom\s*:\s*['\"]", text, re.I):
            if re.search(r"(?:DataTable|dataTable)", text, re.I):
                self.add(
                    "DT-MIG-003", "medium", "medium", "migration", path, line_number(text, match.start()),
                    compact(text[match.start(): match.start() + 120]),
                    "DataTables 2 retains dom for compatibility, but current styling and composition focus on the layout option.",
                    "Plan a separate migration from dom strings to layout and regression-test control placement and Bootstrap integration.",
                    "Confirm this dom property belongs to DataTables rather than another library.",
                )

        if re.search(r"serverSide\s*:\s*true", text, re.I) and re.search(r"\$\.fn\.dataTable\.ext\.search\.push|DataTable\.ext\.search\.push", text, re.I):
            match = re.search(r"serverSide\s*:\s*true", text, re.I)
            assert match
            self.add(
                "DT-STATE-001", "high", "high", "data correctness", path, line_number(text, match.start()),
                "DataTables serverSide: true combined with a client-side search plug-in",
                "Client filtering can apply only to loaded rows while the UI appears to represent the full server dataset.",
                "Move filtering into the server contract or disable server-side processing for the complete in-memory dataset.",
                "Verify whether the plug-in is active for this table and reconcile counts, export, and selection semantics.",
            )

        if re.search(r"(?:new\s+DataTable|\.DataTable)\s*\(", text, re.I) and re.search(r"\bfn[A-Z]\w*\s*\(", text):
            match = re.search(r"\bfn[A-Z]\w*\s*\(", text)
            assert match
            self.add(
                "DT-MIG-004", "high", "high", "migration", path, line_number(text, match.start()),
                "Modern DataTables initialization mixed with legacy fn* calls",
                "The table may fail after upgrading because DataTables 2 removes the legacy API.",
                "Replace legacy calls, then test initialization, redraw, state, extensions, and teardown under one consistent API generation.",
                "Check whether separate routes load different DataTables majors.",
            )

        if re.search(r"deferRender\s*:\s*true|new\s+DataTable|\.DataTable\s*\(", text, re.I) and re.search(r"(?:querySelectorAll|find)\s*\(\s*['\"][^'\"]*(?:tbody\s+tr|tr\s+td)", text, re.I):
            match = re.search(r"(?:querySelectorAll|find)\s*\(\s*['\"][^'\"]*(?:tbody\s+tr|tr\s+td)", text, re.I)
            assert match
            self.add(
                "DT-LIFE-001", "medium", "medium", "lifecycle", path, line_number(text, match.start()),
                compact(text[match.start(): match.start() + 160]),
                "Direct DOM queries may assume all DataTables rows exist even when deferred rendering or paging keeps them out of the DOM.",
                "Use the table API and row identifiers instead of treating rendered nodes as the complete dataset.",
                "Confirm whether the query intentionally targets only the current rendered page.",
            )

        if re.search(r"rowModelType\s*:\s*['\"]serverSide['\"]", text, re.I) and re.search(r"rowSelection\s*:", text, re.I) and not re.search(r"\bgetRowId\s*:", text):
            match = re.search(r"rowModelType\s*:\s*['\"]serverSide['\"]", text, re.I)
            assert match
            self.add(
                "DT-AG-001", "high", "medium", "state", path, line_number(text, match.start()),
                "AG Grid server-side row model with row selection and no static getRowId",
                "Selection can become unstable across block loads, sorting, filtering, and refresh if rows are identified by position.",
                "Provide a stable domain key through getRowId and test selection across server operations.",
                "The ID callback may be composed or imported from another file; verify the runtime options.",
            )

        for option in ("suppressCellFocus", "suppressHeaderFocus"):
            for match in re.finditer(rf"\b{option}\s*:\s*true", text, re.I):
                self.add(
                    "DT-AG-002", "high", "high", "accessibility", path, line_number(text, match.start()),
                    match.group(0),
                    "Keyboard users may lose access to grid cells or headers and their actions.",
                    "Keep grid focus enabled unless an alternate complete keyboard interaction model is provided.",
                    "Test all sorting, filtering, selection, editing, and menu operations without a pointer.",
                )

        if re.search(r"manualPagination\s*:\s*true", text, re.I) and re.search(r"getSortedRowModel\s*\(", text) and not re.search(r"manualSorting\s*:\s*true", text, re.I):
            match = re.search(r"manualPagination\s*:\s*true", text, re.I)
            assert match
            self.add(
                "DT-TAN-001", "high", "high", "data correctness", path, line_number(text, match.start()),
                "TanStack manualPagination with client getSortedRowModel and no manualSorting",
                "Sorting may affect only the currently supplied server page instead of the full dataset.",
                "Move sorting to the server and set manualSorting, or load the complete dataset and use client pagination consistently.",
                "Inspect composed table options and confirm the server actually returns a complete dataset.",
            )

        if re.search(r"manualPagination\s*:\s*true", text, re.I) and re.search(r"getFilteredRowModel\s*\(", text) and not re.search(r"manualFiltering\s*:\s*true", text, re.I):
            match = re.search(r"manualPagination\s*:\s*true", text, re.I)
            assert match
            self.add(
                "DT-TAN-002", "high", "high", "data correctness", path, line_number(text, match.start()),
                "TanStack manualPagination with client getFilteredRowModel and no manualFiltering",
                "Filtering may apply only to the current server page while counts and results imply full-dataset filtering.",
                "Use server-side filtering with manualFiltering, or use a complete client-side dataset consistently.",
                "Inspect the actual data array and query contract.",
            )

        if re.search(r"(?:rowSelection|onRowSelectionChange|getSelectedRowModel)\b", text) and re.search(r"useReactTable\s*\(|useVueTable\s*\(", text) and not re.search(r"\bgetRowId\s*:", text):
            match = re.search(r"(?:rowSelection|onRowSelectionChange|getSelectedRowModel)\b", text)
            assert match
            self.add(
                "DT-TAN-003", "medium", "medium", "state", path, line_number(text, match.start()),
                "TanStack row selection without static getRowId evidence",
                "Default index-based row identifiers can attach selection to the wrong record after sorting, filtering, paging, or refresh.",
                "Provide getRowId using an immutable domain identifier and persist selection by that ID.",
                "The callback may be imported or generated elsewhere; verify runtime options.",
            )

        if re.search(r"(?:useVirtualizer|@tanstack/(?:react|vue)-virtual|react-window|virtualiz)", text, re.I) and re.search(r"role\s*=\s*['\"]grid['\"]", text, re.I) and not re.search(r"aria-rowcount|ariaRowCount", text, re.I):
            match = re.search(r"role\s*=\s*['\"]grid['\"]", text, re.I)
            assert match
            self.add(
                "DT-VIRT-001", "high", "medium", "accessibility", path, line_number(text, match.start()),
                "Virtualized ARIA grid without static aria-rowcount evidence",
                "Assistive technology may not receive the total dataset size or correct position when only a subset exists in the DOM.",
                "Expose total and positional ARIA metadata where known, keep DOM order aligned, and test actual screen-reader behavior.",
                "The library may add metadata at runtime; inspect the rendered accessibility tree.",
            )

        if re.search(r"(?:csvHtml5|excelHtml5|buttons\s*:\s*\[[^\]]*(?:csv|excel)|exportData\s*\()", text, re.I | re.S):
            match = re.search(r"(?:csvHtml5|excelHtml5|exportData\s*\()", text, re.I)
            if match:
                self.add(
                    "DT-EXPORT-001", "medium", "medium", "security", path, line_number(text, match.start()),
                    match.group(0),
                    "Spreadsheet export is present and may expose hidden fields, bypass current permissions, or permit formula injection.",
                    "Define export scope and fields server-side where sensitive, reuse authorization, and apply a deliberate CSV/spreadsheet formula-escaping policy.",
                    "Verify whether export is client-only, which rows it includes, and how values beginning with =, +, -, or @ are handled.",
                )

    def inspect_server_code(self, path: Path, text: str) -> None:
        risky_patterns = [
            r"ORDER\s+BY[^\n;]{0,180}(?:#(?:url|form)\.|\$?_GET\b|\$?_POST\b|Request\s*[\[(]|params?\b|query\b)",
            r"(?:orderBy|sortColumn|sortField)\s*=\s*(?:Request|req\.|request\.|params\[|url\.|form\.|\$_GET|\$_POST)",
            r"ORDER\s+BY\s*[\"']?\s*[+.]\s*(?:Request|req\.|request\.|params\[|url\.|form\.|\$_GET|\$_POST)",
        ]
        for pattern in risky_patterns:
            match = re.search(pattern, text, re.I)
            if match:
                self.add(
                    "DT-SEC-001", "critical", "medium", "security", path, line_number(text, match.start()),
                    compact(match.group(0)),
                    "A request-controlled sort expression may reach ORDER BY without a fixed allowlist, enabling injection or unauthorized field access.",
                    "Map public sort keys to fixed server-side expressions, allowlist direction, and parameterize all values.",
                    "Trace the complete data flow; static matching cannot determine whether prior allowlisting already makes the value safe.",
                )

        if re.search(r"(?:pageSize|length|limit|iDisplayLength)\b", text, re.I) and re.search(r"(?:Request|req\.|request\.|params\[|url\.|form\.|\$_GET|\$_POST)", text, re.I) and not re.search(r"(?:Math\.min|min\s*\(|clamp|maxPageSize|MAX_PAGE|maximumPage)", text, re.I):
            match = re.search(r"(?:pageSize|length|limit|iDisplayLength)\b", text, re.I)
            assert match
            self.add(
                "DT-SEC-002", "medium", "low", "security", path, line_number(text, match.start()),
                compact(text[match.start(): match.start() + 180]),
                "A client-controlled page size may be accepted without an obvious server-side maximum, enabling expensive queries or responses.",
                "Validate numeric bounds and enforce a server-side maximum independent of the UI control.",
                "Confirm whether validation occurs in middleware, schema binding, or a shared query service.",
            )

    def add_cross_project_findings(self, contents: dict[Path, str]) -> None:
        joined = "\n".join(contents.values())
        if re.search(r"jquery\.dataTables|\.dataTable\s*\(", joined, re.I) and re.search(r"new\s+DataTable\s*\(|\bdataTables\.js\b", joined, re.I):
            path = next((p for p, t in contents.items() if re.search(r"jquery\.dataTables|\.dataTable\s*\(", t, re.I)), self.root)
            self.add(
                "DT-MIG-005", "high", "medium", "migration", path, 1,
                "Signals from legacy and modern DataTables generations exist in the same project",
                "Mixed assets or APIs can create duplicate initialization, incompatible extensions, styling conflicts, and uncertain migration state.",
                "Inventory exact core, extension, Bootstrap integration, and route-level bundles; isolate one supported generation per page.",
                "Different routes may intentionally use different bundles, so inspect runtime asset loading before removal.",
            )

        detected_grids = [name for name in ("DataTables", "AG Grid", "TanStack Table", "Handsontable", "Tabulator", "Bootstrap Table") if self.stack[name]]
        if len(detected_grids) >= 3:
            path = next(iter(contents), self.root)
            self.add(
                "DT-ARCH-001", "medium", "high", "architecture", path, 1,
                "Multiple table/grid libraries detected: " + ", ".join(detected_grids),
                "Duplicated interaction models, CSS, accessibility behavior, server adapters, and licenses increase maintenance and migration risk.",
                "Document approved use cases, place libraries behind application adapters, and consolidate incrementally rather than through a global rewrite.",
                "Confirm whether some packages are unused, development-only, or isolated to independent applications.",
            )

    def report(self) -> dict:
        severity_counts = Counter(f.severity for f in self.findings)
        area_counts = Counter(f.area for f in self.findings)
        return {
            "tool": "inspect_data_tables.py",
            "root": str(self.root),
            "files_scanned": self.files_scanned,
            "tables_detected": self.table_count,
            "aria_grids_detected": self.grid_count,
            "detected_stack": dict(sorted(self.stack.items())),
            "package_versions": dict(sorted(self.package_versions.items())),
            "summary": {
                "total_findings": len(self.findings),
                "by_severity": {k: severity_counts.get(k, 0) for k in ("critical", "high", "medium", "low", "info")},
                "by_area": dict(sorted(area_counts.items())),
            },
            "limitations": [
                "Static inspection cannot evaluate runtime-generated markup, computed accessibility trees, server authorization, query correctness, assistive-technology behavior, or WCAG conformance.",
                "Heuristic findings require confirmation in the actual route, dataset, browser, and server contract.",
            ],
            "findings": [asdict(f) for f in self.findings],
        }


def markdown_report(report: dict) -> str:
    lines: list[str] = []
    lines.append("# Data Tables Static Inspection")
    lines.append("")
    lines.append(f"- Root: `{report['root']}`")
    lines.append(f"- Files scanned: **{report['files_scanned']}**")
    lines.append(f"- Native tables detected: **{report['tables_detected']}**")
    lines.append(f"- ARIA grids detected: **{report['aria_grids_detected']}**")
    lines.append(f"- Findings: **{report['summary']['total_findings']}**")
    lines.append("")

    if report["detected_stack"]:
        lines.append("## Detected stack")
        lines.append("")
        for name, count in report["detected_stack"].items():
            lines.append(f"- {name}: {count} signal(s)")
        for package, version in report["package_versions"].items():
            lines.append(f"- `{package}`: `{version}`")
        lines.append("")

    lines.append("## Severity summary")
    lines.append("")
    for severity, count in report["summary"]["by_severity"].items():
        lines.append(f"- {severity.title()}: **{count}**")
    lines.append("")

    if not report["findings"]:
        lines.append("No static findings were produced. Manual and runtime testing are still required.")
    else:
        lines.append("## Findings")
        lines.append("")
        for index, finding in enumerate(report["findings"], start=1):
            lines.append(f"### {index}. [{finding['severity'].upper()}] {finding['rule_id']} - {finding['area']}")
            lines.append("")
            lines.append(f"- Location: `{finding['file']}:{finding['line']}`")
            lines.append(f"- Confidence: **{finding['confidence']}**")
            lines.append(f"- Evidence: `{finding['evidence']}`")
            lines.append(f"- Impact: {finding['impact']}")
            lines.append(f"- Remediation: {finding['remediation']}")
            lines.append(f"- Manual check: {finding['manual_check']}")
            lines.append("")

    lines.append("## Limitations")
    lines.append("")
    for limitation in report["limitations"]:
        lines.append(f"- {limitation}")
    lines.append("")
    return "\n".join(lines)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect semantic tables and data-grid implementations.")
    parser.add_argument("path", type=Path, help="Project directory or source file")
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--output", type=Path, help="Write report to a file instead of stdout")
    parser.add_argument("--fail-on", choices=("low", "medium", "high", "critical"), help="Exit 2 when a finding at or above this severity exists")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    if not args.path.exists():
        print(f"error: path does not exist: {args.path}", file=sys.stderr)
        return 1

    inspector = Inspector(args.path)
    inspector.inspect()
    report = inspector.report()
    output = json.dumps(report, indent=2, ensure_ascii=False) + "\n" if args.format == "json" else markdown_report(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:
        threshold = SEVERITY_ORDER[args.fail_on]
        if any(SEVERITY_ORDER[f.severity] >= threshold for f in inspector.findings):
            return 2
    return 0


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