#!/usr/bin/env python3
"""Static form and data-entry inspection with no third-party dependencies.

The scanner reports evidence and review leads. It does not prove WCAG
conformance, backend validation, authorization, CSRF protection, or runtime
assistive-technology behavior.
"""

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 html.parser import HTMLParser
from pathlib import Path
from typing import Any, Iterable, Optional

MARKUP_EXTENSIONS = {
    ".html", ".htm", ".php", ".cfm", ".cfml", ".jsp", ".jspx",
    ".aspx", ".ascx", ".cshtml", ".razor", ".vue", ".svelte", ".twig",
}
SCRIPT_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"}
STYLE_EXTENSIONS = {".css", ".scss", ".sass", ".less", ".vue", ".svelte"}
ALL_EXTENSIONS = MARKUP_EXTENSIONS | SCRIPT_EXTENSIONS | STYLE_EXTENSIONS
IGNORE_DIRS = {
    ".git", ".svn", ".hg", "node_modules", "vendor", "dist", "build",
    "coverage", ".next", ".nuxt", ".cache", "bin", "obj", "packages",
}
SEVERITY_ORDER = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
CONFIDENCE_ORDER = {"high": 3, "medium": 2, "low": 1}

COMMON_AUTOCOMPLETE = {
    "name": "name",
    "fullname": "name",
    "full_name": "name",
    "firstname": "given-name",
    "first_name": "given-name",
    "givenname": "given-name",
    "lastname": "family-name",
    "last_name": "family-name",
    "familyname": "family-name",
    "email": "email",
    "emailaddress": "email",
    "username": "username",
    "user_name": "username",
    "password": "current-password",
    "currentpassword": "current-password",
    "newpassword": "new-password",
    "phone": "tel",
    "telephone": "tel",
    "tel": "tel",
    "address": "street-address",
    "streetaddress": "street-address",
    "street_address": "street-address",
    "city": "address-level2",
    "state": "address-level1",
    "province": "address-level1",
    "postalcode": "postal-code",
    "postal_code": "postal-code",
    "zipcode": "postal-code",
    "zip": "postal-code",
    "country": "country-name",
    "organization": "organization",
    "company": "organization",
    "ccname": "cc-name",
    "cardholder": "cc-name",
    "cardnumber": "cc-number",
    "creditcard": "cc-number",
    "cvc": "cc-csc",
    "cvv": "cc-csc",
    "otp": "one-time-code",
    "one_time_code": "one-time-code",
}

IDENTIFIER_NAMES = re.compile(
    r"(?:phone|tel|mobile|postal|zip|card|cc-?number|account|iban|routing|tax.?id|ssn)",
    re.I,
)
TYPE_HINTS = {
    "email": "email",
    "mail": "email",
    "phone": "tel",
    "telephone": "tel",
    "mobile": "tel",
    "website": "url",
    "url": "url",
}
SENSITIVE_STORAGE = re.compile(
    r"(?:localStorage|sessionStorage)\s*\.\s*(?:setItem|\w+\s*=).*?(?:password|passwd|token|secret|otp|ssn|credit.?card|card.?number|cvv|cvc)",
    re.I | re.S,
)


@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 Element:
    tag: str
    attrs: dict[str, str]
    line: int
    form_index: Optional[int]
    fieldset_index: Optional[int]
    wrapped_by_label: bool
    text: str = ""


@dataclass
class FormInfo:
    attrs: dict[str, str]
    line: int
    controls: list[int]
    buttons: list[int]


@dataclass
class FieldsetInfo:
    line: int
    has_legend: bool


class FormParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.elements: list[Element] = []
        self.forms: list[FormInfo] = []
        self.fieldsets: list[FieldsetInfo] = []
        self.form_stack: list[int] = []
        self.fieldset_stack: list[int] = []
        self.label_depth = 0
        self.labels_for: set[str] = set()
        self.ids: dict[str, list[int]] = defaultdict(list)
        self.id_references: list[tuple[str, str, int]] = []
        self.roles: list[Element] = []
        self.button_stack: list[int] = []

    @staticmethod
    def attrs_dict(attrs: list[tuple[str, Optional[str]]]) -> dict[str, str]:
        return {k.lower(): (v if v is not None else "") for k, v in attrs}

    def handle_starttag(self, tag: str, attrs_raw: list[tuple[str, Optional[str]]]) -> None:
        tag = tag.lower()
        attrs = self.attrs_dict(attrs_raw)
        line = self.getpos()[0]

        if tag == "form":
            idx = len(self.forms)
            self.forms.append(FormInfo(attrs=attrs, line=line, controls=[], buttons=[]))
            self.form_stack.append(idx)
        elif tag == "fieldset":
            idx = len(self.fieldsets)
            self.fieldsets.append(FieldsetInfo(line=line, has_legend=False))
            self.fieldset_stack.append(idx)
        elif tag == "legend" and self.fieldset_stack:
            self.fieldsets[self.fieldset_stack[-1]].has_legend = True
        elif tag == "label":
            self.label_depth += 1
            target = attrs.get("for", "").strip()
            if target:
                self.labels_for.add(target)

        form_index = self.form_stack[-1] if self.form_stack else None
        fieldset_index = self.fieldset_stack[-1] if self.fieldset_stack else None
        element = Element(
            tag=tag,
            attrs=attrs,
            line=line,
            form_index=form_index,
            fieldset_index=fieldset_index,
            wrapped_by_label=self.label_depth > 0,
            text="",
        )
        element_index = len(self.elements)
        self.elements.append(element)
        if tag == "button":
            self.button_stack.append(element_index)

        if form_index is not None:
            if tag in {"input", "select", "textarea"}:
                self.forms[form_index].controls.append(element_index)
            elif tag == "button":
                self.forms[form_index].buttons.append(element_index)

        element_id = attrs.get("id", "").strip()
        if element_id:
            self.ids[element_id].append(line)

        for attr_name in ("aria-labelledby", "aria-describedby", "aria-errormessage", "aria-controls"):
            for ref in attrs.get(attr_name, "").split():
                if ref:
                    self.id_references.append((attr_name, ref, line))

        if "role" in attrs:
            self.roles.append(element)

    def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
        self.handle_starttag(tag, attrs)

    def handle_endtag(self, tag: str) -> None:
        tag = tag.lower()
        if tag == "form" and self.form_stack:
            self.form_stack.pop()
        elif tag == "fieldset" and self.fieldset_stack:
            self.fieldset_stack.pop()
        elif tag == "label" and self.label_depth:
            self.label_depth -= 1
        elif tag == "button" and self.button_stack:
            self.button_stack.pop()

    def handle_data(self, data: str) -> None:
        for index in self.button_stack:
            self.elements[index].text += data


def normalize_identifier(value: str) -> str:
    return re.sub(r"[^a-z0-9_]+", "", value.lower())


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


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


def add_finding(findings: list[Finding], **kwargs: Any) -> None:
    findings.append(Finding(**kwargs))


def has_accessible_name(element: Element, parser: FormParser) -> bool:
    attrs = element.attrs
    element_id = attrs.get("id", "").strip()
    if element.wrapped_by_label:
        return True
    if element_id and element_id in parser.labels_for:
        return True
    if attrs.get("aria-label", "").strip() or attrs.get("aria-labelledby", "").strip():
        return True
    if element.tag == "input" and attrs.get("type", "text").lower() == "image" and attrs.get("alt", "").strip():
        return True
    return False


def scan_markup(path: Path, text: str, relative: str, findings: list[Finding]) -> None:
    parser = FormParser()
    try:
        parser.feed(text)
    except Exception as exc:
        add_finding(
            findings,
            rule_id="FORM-PARSE-001",
            severity="info",
            confidence="low",
            area="analysis",
            file=relative,
            line=1,
            evidence=f"Markup parser could not fully process the file: {exc}",
            impact="Some generated or templated form markup may not be inspected.",
            remediation="Inspect rendered DOM and server-generated markup manually.",
            manual_check="Required for templated or runtime-generated controls.",
        )

    for element_id, lines in parser.ids.items():
        if len(lines) > 1:
            add_finding(
                findings,
                rule_id="FORM-HTML-001",
                severity="high",
                confidence="high",
                area="semantics",
                file=relative,
                line=lines[1],
                evidence=f"Duplicate id '{element_id}' appears on lines {', '.join(map(str, lines))}.",
                impact="Labels, error references, scripting, and assistive technology relationships can target the wrong element.",
                remediation="Generate a unique id for every control and update all label and ARIA references.",
            )

    known_ids = set(parser.ids)
    for attr_name, ref, line in parser.id_references:
        if ref not in known_ids and not re.search(r"[{<%$]", ref):
            add_finding(
                findings,
                rule_id="FORM-ARIA-001",
                severity="high",
                confidence="high",
                area="accessibility",
                file=relative,
                line=line,
                evidence=f"{attr_name} references missing id '{ref}'.",
                impact="The intended label, description, error, or controlled element is not programmatically connected.",
                remediation="Create the referenced element with a unique id or correct/remove the reference.",
            )

    radio_groups: dict[tuple[Optional[int], str], list[Element]] = defaultdict(list)
    file_controls: list[Element] = []
    password_controls: list[Element] = []

    for element in parser.elements:
        if element.tag not in {"input", "select", "textarea", "button"}:
            continue
        attrs = element.attrs
        input_type = attrs.get("type", "text").lower() if element.tag == "input" else element.tag
        disabled = "disabled" in attrs

        if element.tag == "button" and element.form_index is not None and "type" not in attrs:
            add_finding(
                findings,
                rule_id="FORM-BUTTON-001",
                severity="medium",
                confidence="high",
                area="behavior",
                file=relative,
                line=element.line,
                evidence="Button inside a form has no explicit type and therefore defaults to submit.",
                impact="A secondary action can submit the form unexpectedly.",
                remediation="Set type='button' for non-submit actions or type='submit' intentionally.",
            )

        if element.tag == "button":
            if not has_accessible_name(element, parser) and not element.text.strip():
                text_hint = attrs.get("title", "").strip()
                if not text_hint:
                    add_finding(
                        findings,
                        rule_id="FORM-NAME-002",
                        severity="high",
                        confidence="medium",
                        area="accessibility",
                        file=relative,
                        line=element.line,
                        evidence="Button has no detectable label or ARIA accessible name in static markup.",
                        impact="Screen reader and speech-input users may not know or invoke the action.",
                        remediation="Provide visible button text or an accurate accessible name that includes the visible label.",
                        manual_check="Confirm runtime text or icon labelling in the rendered DOM.",
                    )
            continue

        if input_type in {"hidden", "submit", "reset", "button", "image"}:
            continue

        if not disabled and not attrs.get("name", "").strip():
            add_finding(
                findings,
                rule_id="FORM-DATA-001",
                severity="high",
                confidence="medium",
                area="data-contract",
                file=relative,
                line=element.line,
                evidence=f"{element.tag} control has no name attribute.",
                impact="The value is omitted from native form submission and may depend on fragile custom serialization.",
                remediation="Add the backend contract name or document and test the intentional JavaScript-only data path.",
                manual_check="Check framework bindings and submitted payloads.",
            )

        if not has_accessible_name(element, parser):
            placeholder = attrs.get("placeholder", "").strip()
            add_finding(
                findings,
                rule_id="FORM-NAME-001",
                severity="high",
                confidence="high" if placeholder else "medium",
                area="accessibility",
                file=relative,
                line=element.line,
                evidence=(
                    f"Control has placeholder '{compact(placeholder)}' but no persistent label."
                    if placeholder
                    else "Control has no detectable label or ARIA accessible name in static markup."
                ),
                impact="Users may not understand the requested value, especially after entering data or when using assistive technology.",
                remediation="Add a visible label associated with for/id; keep help and examples separate from the label.",
                manual_check="Confirm framework-generated labels in the rendered DOM." if not placeholder else "",
            )

        control_name = attrs.get("name", "") or attrs.get("id", "")
        normalized = normalize_identifier(control_name)
        autocomplete = attrs.get("autocomplete", "").strip().lower()

        expected_autocomplete = COMMON_AUTOCOMPLETE.get(normalized)
        if expected_autocomplete and not autocomplete:
            add_finding(
                findings,
                rule_id="FORM-AUTOFILL-001",
                severity="low",
                confidence="medium",
                area="usability",
                file=relative,
                line=element.line,
                evidence=f"Field '{control_name}' appears to represent {expected_autocomplete} but has no autocomplete token.",
                impact="Autofill, password managers, and input-purpose identification may be less reliable.",
                remediation=f"Confirm the data meaning and add autocomplete='{expected_autocomplete}' when appropriate.",
                manual_check="Do not add a token based only on the field name; confirm the actual purpose.",
            )

        if element.tag == "input" and input_type == "text":
            for key, recommended_type in TYPE_HINTS.items():
                if key in normalized:
                    add_finding(
                        findings,
                        rule_id="FORM-TYPE-001",
                        severity="low",
                        confidence="medium",
                        area="mobile-input",
                        file=relative,
                        line=element.line,
                        evidence=f"Text field '{control_name}' may represent {recommended_type} data.",
                        impact="Users may miss browser validation, semantic behavior, and optimized mobile keyboards.",
                        remediation=f"Confirm the data model and use type='{recommended_type}' if it accurately represents the value.",
                        manual_check="Special identifiers or permissive formats may intentionally remain text.",
                    )
                    break

        if element.tag == "input" and input_type == "number" and IDENTIFIER_NAMES.search(control_name):
            add_finding(
                findings,
                rule_id="FORM-TYPE-002",
                severity="medium",
                confidence="high",
                area="data-entry",
                file=relative,
                line=element.line,
                evidence=f"Identifier-like field '{control_name}' uses type='number'.",
                impact="Leading zeros, long identifiers, locale-independent formatting, and non-arithmetic characters can be lost or rejected.",
                remediation="Use type='text' with an appropriate inputmode and explicit validation for identifiers.",
            )

        if input_type in {"radio", "checkbox"}:
            if input_type == "radio" and attrs.get("name", ""):
                radio_groups[(element.form_index, attrs["name"])].append(element)

        if input_type == "file":
            file_controls.append(element)
            if not attrs.get("accept", "").strip():
                add_finding(
                    findings,
                    rule_id="FORM-UPLOAD-001",
                    severity="low",
                    confidence="high",
                    area="upload",
                    file=relative,
                    line=element.line,
                    evidence="File input has no accept hint.",
                    impact="The file picker cannot guide users toward expected file types.",
                    remediation="Add an accurate accept list as a usability hint and keep authoritative server validation.",
                )

        if input_type == "password":
            password_controls.append(element)
            if autocomplete == "off":
                add_finding(
                    findings,
                    rule_id="FORM-AUTH-001",
                    severity="high",
                    confidence="high",
                    area="authentication",
                    file=relative,
                    line=element.line,
                    evidence="Password field sets autocomplete='off'.",
                    impact="Password managers and accessible authentication assistance can be impaired.",
                    remediation="Use current-password or new-password according to the field purpose unless a documented security exception applies.",
                )
            elif not autocomplete:
                add_finding(
                    findings,
                    rule_id="FORM-AUTH-002",
                    severity="medium",
                    confidence="high",
                    area="authentication",
                    file=relative,
                    line=element.line,
                    evidence="Password field has no explicit autocomplete purpose.",
                    impact="Browsers and password managers may classify the credential field incorrectly.",
                    remediation="Set autocomplete='current-password' or 'new-password' according to the workflow.",
                )

        if "onpaste" in attrs and re.search(r"return\s+false|preventDefault", attrs["onpaste"], re.I):
            add_finding(
                findings,
                rule_id="FORM-PASTE-001",
                severity="high",
                confidence="high",
                area="accessibility",
                file=relative,
                line=element.line,
                evidence="Control blocks paste with an inline handler.",
                impact="Password managers, OTP transfer, assistive workflows, and error-free data entry are impaired.",
                remediation="Allow paste and validate the resulting value normally.",
            )

        if "required" in attrs and attrs.get("aria-hidden", "").lower() == "true":
            add_finding(
                findings,
                rule_id="FORM-HIDDEN-001",
                severity="high",
                confidence="high",
                area="behavior",
                file=relative,
                line=element.line,
                evidence="Required control is aria-hidden.",
                impact="The browser can block submission on a field that assistive technology cannot reach or understand.",
                remediation="Remove aria-hidden or disable/remove the inactive control and its requirement consistently.",
            )

    for (_form_index, name), controls in radio_groups.items():
        if len(controls) > 1:
            fieldsets = {c.fieldset_index for c in controls}
            valid_group = False
            if len(fieldsets) == 1:
                fieldset_index = next(iter(fieldsets))
                valid_group = fieldset_index is not None and parser.fieldsets[fieldset_index].has_legend
            if not valid_group:
                add_finding(
                    findings,
                    rule_id="FORM-GROUP-001",
                    severity="medium",
                    confidence="high",
                    area="accessibility",
                    file=relative,
                    line=controls[0].line,
                    evidence=f"Radio group '{name}' is not contained in a fieldset with a legend.",
                    impact="The shared question or context may not be announced with each option.",
                    remediation="Wrap related options in fieldset and provide the group question in legend.",
                )

    for role_element in parser.roles:
        role = role_element.attrs.get("role", "").lower()
        attrs = role_element.attrs
        if role == "combobox":
            missing = [name for name in ("aria-expanded", "aria-controls") if not attrs.get(name, "").strip()]
            if missing:
                add_finding(
                    findings,
                    rule_id="FORM-WIDGET-001",
                    severity="high",
                    confidence="high",
                    area="custom-widget",
                    file=relative,
                    line=role_element.line,
                    evidence=f"Custom combobox is missing {', '.join(missing)}.",
                    impact="Assistive technology cannot reliably understand popup availability and state.",
                    remediation="Implement the complete combobox pattern, including focus, keyboard, popup role, active option, state, and testing.",
                    manual_check="ARIA attributes alone are insufficient; test the full interaction.",
                )
        elif role == "dialog":
            has_name = bool(attrs.get("aria-label", "").strip() or attrs.get("aria-labelledby", "").strip())
            if not has_name:
                add_finding(
                    findings,
                    rule_id="FORM-WIDGET-002",
                    severity="high",
                    confidence="high",
                    area="custom-widget",
                    file=relative,
                    line=role_element.line,
                    evidence="Dialog has no accessible name.",
                    impact="Users cannot identify the purpose of the dialog, including form or date-picker dialogs.",
                    remediation="Reference a visible dialog title with aria-labelledby or provide an accurate aria-label.",
                )

    for form in parser.forms:
        attrs = form.attrs
        controls = [parser.elements[i] for i in form.controls]
        control_types = {
            e.attrs.get("type", "text").lower() if e.tag == "input" else e.tag
            for e in controls
        }
        if attrs.get("autocomplete", "").lower() == "off" and ({"password", "email", "tel"} & control_types or password_controls):
            add_finding(
                findings,
                rule_id="FORM-AUTOFILL-002",
                severity="medium",
                confidence="medium",
                area="usability",
                file=relative,
                line=form.line,
                evidence="Form disables autocomplete for fields that may benefit from autofill or password-manager support.",
                impact="Users may need to remember and re-enter information unnecessarily.",
                remediation="Remove form-wide autocomplete='off' and configure only exceptional fields explicitly.",
                manual_check="Confirm the form's actual data sensitivity and reuse semantics.",
            )
        if "novalidate" in attrs:
            add_finding(
                findings,
                rule_id="FORM-VALIDATE-001",
                severity="low",
                confidence="high",
                area="validation",
                file=relative,
                line=form.line,
                evidence="Form disables browser constraint validation with novalidate.",
                impact="Native error assistance is bypassed and a complete custom validation path becomes mandatory.",
                remediation="Confirm custom validation covers labels, associations, focus, messages, server errors, and no-JavaScript behavior where required.",
                manual_check="novalidate can be intentional and valid when the replacement is complete.",
            )
        method = attrs.get("method", "get").lower()
        if method == "post":
            hidden_names = {
                normalize_identifier(e.attrs.get("name", ""))
                for e in controls
                if e.tag == "input" and e.attrs.get("type", "").lower() == "hidden"
            }
            if not any("csrf" in name or "requestverificationtoken" in name or "xsrf" in name for name in hidden_names):
                add_finding(
                    findings,
                    rule_id="FORM-SECURITY-001",
                    severity="medium",
                    confidence="low",
                    area="security",
                    file=relative,
                    line=form.line,
                    evidence="POST form has no statically detectable CSRF token field.",
                    impact="Cookie-authenticated state changes require an architecture-appropriate CSRF defense.",
                    remediation="Verify server middleware, token, SameSite, and origin-check strategy; do not add a token blindly.",
                    manual_check="Frameworks can inject or validate CSRF tokens outside the visible template.",
                )
        has_submit = any(
            (e.tag == "button" and e.attrs.get("type", "submit").lower() != "button")
            or (e.tag == "input" and e.attrs.get("type", "text").lower() in {"submit", "image"})
            for e in parser.elements
            if e.form_index is not None and parser.forms[e.form_index] is form
        )
        if controls and not has_submit:
            add_finding(
                findings,
                rule_id="FORM-SUBMIT-001",
                severity="low",
                confidence="low",
                area="behavior",
                file=relative,
                line=form.line,
                evidence="Form has controls but no statically detectable submit control.",
                impact="Submission may depend entirely on JavaScript or an external control and can lose native keyboard behavior.",
                remediation="Confirm an associated submit control exists and that Enter, keyboard, and no-script behavior meet requirements.",
                manual_check="A submit button can use the form attribute outside the form or be rendered dynamically.",
            )

    if file_controls:
        add_finding(
            findings,
            rule_id="FORM-UPLOAD-002",
            severity="medium",
            confidence="high",
            area="security",
            file=relative,
            line=file_controls[0].line,
            evidence=f"File upload control detected ({len(file_controls)} total).",
            impact="Client accept filters and filename checks do not protect storage or downstream processing.",
            remediation="Verify server allow-listing, content inspection, size/decompression limits, generated storage names, malware handling, authorization, and retention.",
            manual_check="Backend and infrastructure review required.",
        )

    if re.search(r"class\s*=\s*[\"'][^\"']*(?:error|invalid-feedback|field-validation-error)[^\"']*[\"']", text, re.I):
        if not re.search(r"aria-(?:describedby|errormessage)\s*=", text, re.I):
            match = re.search(r"class\s*=\s*[\"'][^\"']*(?:error|invalid-feedback|field-validation-error)", text, re.I)
            line = line_for_offset(text, match.start()) if match else 1
            add_finding(
                findings,
                rule_id="FORM-ERROR-001",
                severity="medium",
                confidence="medium",
                area="accessibility",
                file=relative,
                line=line,
                evidence="Visible error markup is present but no aria-describedby or aria-errormessage association was detected.",
                impact="Screen reader users may not hear the field-specific error when navigating to the control.",
                remediation="Give error text a stable id and reference it from the invalid control; maintain the relation when messages change.",
                manual_check="Frameworks may add associations at runtime.",
            )


def regex_findings(path: Path, text: str, relative: str, findings: list[Finding]) -> None:
    suffix = path.suffix.lower()

    if suffix in SCRIPT_EXTENSIONS or suffix in MARKUP_EXTENSIONS:
        patterns = [
            (
                "FORM-PASTE-002", "high", "high", "accessibility",
                re.compile(r"(?:paste|onpaste)[\s\S]{0,160}(?:preventDefault\s*\(|return\s+false)", re.I),
                "Script appears to prevent paste in a form-related interaction.",
                "Password managers, OTP workflows, assistive technology, and accurate data entry can be blocked.",
                "Remove paste prevention and validate the resulting value normally.",
            ),
            (
                "FORM-KEYBOARD-001", "high", "medium", "accessibility",
                re.compile(r"(?:keydown|keypress)[\s\S]{0,180}(?:Enter|keyCode\s*={2,3}\s*13)[\s\S]{0,120}preventDefault\s*\(", re.I),
                "Script may block Enter-key behavior.",
                "Keyboard users may be unable to submit or operate expected form behavior.",
                "Restrict prevention to a documented widget interaction and preserve normal form submission elsewhere.",
            ),
            (
                "FORM-STORAGE-001", "critical", "medium", "privacy-security",
                SENSITIVE_STORAGE,
                "Sensitive-looking data may be written to localStorage or sessionStorage.",
                "Browser storage can expose long-lived secrets or personal data to scripts and shared-device users.",
                "Do not store passwords, OTPs, full payment data, or secrets in Web Storage; minimize and protect any persisted draft data.",
            ),
            (
                "FORM-ERROR-002", "medium", "medium", "feedback",
                re.compile(r"\balert\s*\(\s*(?:error|err|message|['\"](?:invalid|error|failed))", re.I),
                "Form errors may be reported with alert().",
                "Blocking alerts lose field context and can create poor focus and recovery behavior.",
                "Render an error summary and associated inline messages; reserve dialogs for true interruptions.",
            ),
            (
                "FORM-INJECTION-001", "high", "medium", "security",
                re.compile(r"(?:innerHTML|outerHTML|insertAdjacentHTML|dangerouslySetInnerHTML|v-html)\s*(?:=|\(|:)", re.I),
                "HTML injection API detected near frontend code.",
                "Untrusted values in validation or server error messages can create cross-site scripting risk.",
                "Use textContent or framework text binding; sanitize intentional rich HTML with a maintained allow-list and validate server output.",
            ),
        ]
        for rule_id, severity, confidence, area, pattern, evidence, impact, remediation in patterns:
            for match in pattern.finditer(text):
                add_finding(
                    findings,
                    rule_id=rule_id,
                    severity=severity,
                    confidence=confidence,
                    area=area,
                    file=relative,
                    line=line_for_offset(text, match.start()),
                    evidence=evidence,
                    impact=impact,
                    remediation=remediation,
                    manual_check="Inspect the complete handler and data flow before changing behavior.",
                )
                break

        submit_handler = re.search(r"(?:addEventListener\s*\(\s*['\"]submit|\.on\s*\(\s*['\"]submit|onsubmit\s*=)", text, re.I)
        async_submit = re.search(r"\b(?:fetch|axios\.|XMLHttpRequest|\$\.ajax|\$\.post)\b", text)
        if submit_handler and async_submit and not re.search(r"(?:disabled\s*=|setAttribute\s*\(\s*['\"]disabled|isSubmitting|submitting|AbortController|idempotency)", text, re.I):
            add_finding(
                findings,
                rule_id="FORM-DUPLICATE-001",
                severity="medium",
                confidence="low",
                area="resilience",
                file=relative,
                line=line_for_offset(text, submit_handler.start()),
                evidence="Asynchronous submit handler detected without an obvious in-flight or duplicate-submission guard.",
                impact="Repeated activation can create duplicate records, requests, messages, or charges.",
                remediation="Add a recoverable client in-flight state and server-side idempotency or duplicate detection for consequential operations.",
                manual_check="Framework state or server protection may exist outside this file.",
            )

        if async_submit and re.search(r"(?:\.innerHTML|textContent|\.html\()\s*=?.*?(?:success|saved|complete)", text, re.I | re.S):
            if not re.search(r"aria-live|role\s*=\s*['\"](?:status|alert)", text, re.I):
                add_finding(
                    findings,
                    rule_id="FORM-STATUS-001",
                    severity="medium",
                    confidence="low",
                    area="accessibility",
                    file=relative,
                    line=line_for_offset(text, async_submit.start()),
                    evidence="Asynchronous form feedback detected without an obvious live status region.",
                    impact="Screen reader users may not learn that saving, submission, or failure occurred.",
                    remediation="Expose concise status messages through an appropriate role/status mechanism without moving focus unnecessarily.",
                    manual_check="Confirm rendered DOM and framework components.",
                )

    if suffix in STYLE_EXTENSIONS or suffix in MARKUP_EXTENSIONS:
        style_patterns = [
            (
                "FORM-FOCUS-001", "high", "high", "accessibility",
                re.compile(r"(?:input|select|textarea|button|\.form-control|:focus)[^{]*\{[^}]*outline\s*:\s*(?:none|0)\b", re.I | re.S),
                "Form-related focus outline is removed.",
                "Keyboard users can lose track of the active control.",
                "Provide a visible focus indicator with sufficient contrast; do not remove outline without an equivalent replacement.",
            ),
            (
                "FORM-MOBILE-001", "low", "medium", "mobile-input",
                re.compile(r"(?:input|select|textarea|\.form-control)[^{]*\{[^}]*font-size\s*:\s*(?:[0-9]|1[0-5])px\b", re.I | re.S),
                "A form control may use a font size below 16px.",
                "Some mobile browsers can zoom unexpectedly on focus and small text can reduce readability.",
                "Use responsive relative sizing and test mobile focus behavior and text scaling.",
            ),
            (
                "FORM-REFLOW-001", "medium", "medium", "responsive",
                re.compile(r"(?:input|select|textarea|form|\.form-control)[^{]*\{[^}]*(?:width|min-width)\s*:\s*(?:[4-9][0-9]{2}|[1-9][0-9]{3,})px\b", re.I | re.S),
                "A form or control has a large fixed pixel width.",
                "The form may overflow at narrow widths, zoom, long translations, or text enlargement.",
                "Use max-width with fluid inline sizing and test at 320 CSS px and high zoom.",
            ),
            (
                "FORM-SELECTION-001", "medium", "medium", "usability",
                re.compile(r"(?:input|textarea|\.form-control)[^{]*\{[^}]*user-select\s*:\s*none", re.I | re.S),
                "Text selection may be disabled on form controls.",
                "Users may be unable to select, copy, replace, or inspect entered values.",
                "Allow normal text selection unless a narrow documented interaction requires otherwise.",
            ),
        ]
        for rule_id, severity, confidence, area, pattern, evidence, impact, remediation in style_patterns:
            match = pattern.search(text)
            if match:
                add_finding(
                    findings,
                    rule_id=rule_id,
                    severity=severity,
                    confidence=confidence,
                    area=area,
                    file=relative,
                    line=line_for_offset(text, match.start()),
                    evidence=evidence,
                    impact=impact,
                    remediation=remediation,
                    manual_check="Inspect computed styles and supported forced-colors/high-contrast behavior.",
                )


def iter_files(root: Path, max_bytes: int) -> Iterable[Path]:
    if root.is_file():
        if root.suffix.lower() in ALL_EXTENSIONS and root.stat().st_size <= max_bytes:
            yield root
        return

    for current, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".terraform")]
        for name in files:
            path = Path(current) / name
            if path.suffix.lower() not in ALL_EXTENSIONS:
                continue
            try:
                if path.stat().st_size <= max_bytes:
                    yield path
            except OSError:
                continue


def deduplicate(findings: list[Finding]) -> list[Finding]:
    seen: set[tuple[str, str, int]] = set()
    result: list[Finding] = []
    for finding in findings:
        key = (finding.rule_id, finding.file, finding.line)
        if key not in seen:
            seen.add(key)
            result.append(finding)
    return result


def summarize(findings: list[Finding], files_scanned: int) -> dict[str, Any]:
    severity = Counter(f.severity for f in findings)
    confidence = Counter(f.confidence for f in findings)
    areas = Counter(f.area for f in findings)
    return {
        "files_scanned": files_scanned,
        "finding_count": len(findings),
        "severity": dict(sorted(severity.items(), key=lambda item: -SEVERITY_ORDER[item[0]])),
        "confidence": dict(sorted(confidence.items(), key=lambda item: -CONFIDENCE_ORDER[item[0]])),
        "areas": dict(areas.most_common()),
        "limitations": [
            "Static analysis cannot prove WCAG conformance or runtime accessibility.",
            "Backend validation, authorization, CSRF, idempotency, storage, and privacy controls require server review.",
            "Templated and framework-generated markup must be checked in the rendered DOM.",
            "Critical flows require keyboard, browser, mobile, and assistive-technology testing.",
        ],
    }


def markdown_report(root: Path, findings: list[Finding], summary: dict[str, Any]) -> str:
    lines = [
        "# Forms and Data Entry Static Review",
        "",
        f"- Target: `{root}`",
        f"- Files scanned: {summary['files_scanned']}",
        f"- Findings: {summary['finding_count']}",
        "",
        "## Severity summary",
        "",
        "| Severity | Count |",
        "| --- | ---: |",
    ]
    for name in ("critical", "high", "medium", "low", "info"):
        lines.append(f"| {name.title()} | {summary['severity'].get(name, 0)} |")

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

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


def threshold_failed(findings: list[Finding], threshold: Optional[str]) -> bool:
    if not threshold:
        return False
    minimum = SEVERITY_ORDER[threshold]
    return any(SEVERITY_ORDER[f.severity] >= minimum for f in findings)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Inspect form and data-entry code for static risks.")
    parser.add_argument("path", help="Project directory or file to inspect")
    parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
    parser.add_argument("--output", help="Write the report to this path instead of stdout")
    parser.add_argument("--fail-on", choices=("critical", "high", "medium", "low", "info"))
    parser.add_argument("--max-file-bytes", type=int, default=2_000_000)
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    root = Path(args.path).expanduser().resolve()
    if not root.exists():
        print(f"Error: path does not exist: {root}", file=sys.stderr)
        return 2

    findings: list[Finding] = []
    files = list(iter_files(root, args.max_file_bytes))
    base = root if root.is_dir() else root.parent

    for path in files:
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError as exc:
            print(f"Warning: cannot read {path}: {exc}", file=sys.stderr)
            continue
        relative = str(path.relative_to(base))
        if path.suffix.lower() in MARKUP_EXTENSIONS:
            scan_markup(path, text, relative, findings)
        regex_findings(path, text, relative, findings)

    findings = deduplicate(findings)
    findings.sort(
        key=lambda f: (
            -SEVERITY_ORDER[f.severity],
            -CONFIDENCE_ORDER[f.confidence],
            f.file,
            f.line,
            f.rule_id,
        )
    )
    summary = summarize(findings, len(files))

    if args.format == "json":
        payload = {
            "tool": "inspect_forms.py",
            "target": str(root),
            "summary": summary,
            "findings": [asdict(f) for f in findings],
        }
        report = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
    else:
        report = markdown_report(root, findings, summary)

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

    return 1 if threshold_failed(findings, args.fail_on) else 0


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