"""Web compliance scanner — checks security headers, OWASP risks, a11y, and privacy."""

from __future__ import annotations

import json
import re
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

from ..engine.runner import Finding
from ..models.frameworks import detect_web_frameworks, get_redirect_allowlists, get_sanitizers


def scan(project_root: Path) -> List[Finding]:
    """Run all web compliance checks and return findings."""
    findings: List[Finding] = []
    root = Path(project_root).resolve()

    config_contents = _collect_config_files(root)
    source_contents = _collect_source(root)
    html_contents = _collect_html(root)
    frameworks = detect_web_frameworks(root, source_contents + config_contents)

    findings.extend(_check_security_headers(config_contents, source_contents))
    findings.extend(_check_owasp(root, source_contents, html_contents, frameworks))
    findings.extend(_check_secrets(source_contents))
    findings.extend(_check_accessibility(root, html_contents + source_contents))
    findings.extend(_check_privacy(root, html_contents + source_contents, config_contents))

    return findings


# ---------------------------------------------------------------------------
# File loaders
# ---------------------------------------------------------------------------

_CONFIG_GLOBS = [
    "next.config.*", "nuxt.config.*", "astro.config.*",
    "vercel.json", "netlify.toml", "_headers", ".htaccess",
    "nginx.conf", "server.js", "server.ts", "app.js", "app.ts",
    "vite.config.*", "vue.config.*", "svelte.config.*",
]


def _collect_config_files(root: Path) -> str:
    chunks: List[str] = []
    for pattern in _CONFIG_GLOBS:
        for f in root.glob(pattern):
            try:
                chunks.append(f.read_text(errors="ignore"))
            except OSError:
                continue
    return "\n".join(chunks)


def _collect_source(root: Path, limit: int = 300) -> str:
    """Collect JS/TS/Dart source files (excluding build artifacts)."""
    chunks: List[str] = []
    count = 0
    for ext in ("*.js", "*.ts", "*.jsx", "*.tsx", "*.dart"):
        if count >= limit:
            break
        for src in root.rglob(ext):
            rel = str(src.relative_to(root))
            if any(skip in rel for skip in ["node_modules/", "dist/", "build/", ".next/", ".nuxt/", ".dart_tool/"]):
                continue
            try:
                chunks.append(src.read_text(errors="ignore"))
            except OSError:
                continue
            count += 1
            if count >= limit:
                break
    return "\n".join(chunks)


def _collect_source_records(root: Path, limit: int = 300) -> List[Tuple[Path, str]]:
    """Collect source files as (path, content) for evidence-aware checks."""
    records: List[Tuple[Path, str]] = []
    count = 0
    for ext in ("*.js", "*.ts", "*.jsx", "*.tsx", "*.dart"):
        if count >= limit:
            break
        for src in root.rglob(ext):
            rel = str(src.relative_to(root))
            if any(skip in rel for skip in ["node_modules/", "dist/", "build/", ".next/", ".nuxt/", ".dart_tool/"]):
                continue
            try:
                records.append((src, src.read_text(errors="ignore")))
            except OSError:
                continue
            count += 1
            if count >= limit:
                break
    return records


def _collect_html(root: Path, limit: int = 200) -> str:
    """Collect HTML/template files."""
    chunks: List[str] = []
    count = 0
    for ext in ("*.html", "*.vue", "*.svelte", "*.astro", "*.hbs", "*.ejs"):
        if count >= limit:
            break
        for src in root.rglob(ext):
            rel = str(src.relative_to(root))
            if any(skip in rel for skip in ["node_modules/", "dist/", "build/", ".next/"]):
                continue
            try:
                chunks.append(src.read_text(errors="ignore"))
            except OSError:
                continue
            count += 1
            if count >= limit:
                break
    return "\n".join(chunks)


def _iter_code_lines(text: str) -> List[Tuple[int, str]]:
    """Return non-comment code lines preserving line numbers."""
    # Some fixtures/source snapshots can contain escaped newline literals.
    # Normalize them so control-flow and call extraction stay stable.
    if "\\n" in text:
        text = text.replace("\\r\\n", "\n").replace("\\n", "\n")

    out: List[Tuple[int, str]] = []
    in_block = False
    for idx, raw in enumerate(text.splitlines(), start=1):
        line = raw
        if in_block:
            end = line.find("*/")
            if end == -1:
                continue
            line = line[end + 2 :]
            in_block = False

        while True:
            start = line.find("/*")
            if start == -1:
                break
            end = line.find("*/", start + 2)
            if end == -1:
                line = line[:start]
                in_block = True
                break
            line = line[:start] + line[end + 2 :]

        line = re.sub(r"(?<!:)//.*$", "", line)
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith(("*", "//")):
            continue
        out.append((idx, line))
    return out


def _extract_js_functions(lines: List[Tuple[int, str]]) -> Dict[str, Dict[str, object]]:
    """Extract lightweight JS/TS function summaries from code lines."""
    functions: Dict[str, Dict[str, object]] = {}
    i = 0
    while i < len(lines):
        lineno, line = lines[i]
        m = re.search(r"\bfunction\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*\{", line)
        if not m:
            m = re.search(r"\b(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(([^)]*)\)\s*=>\s*\{", line)
        if not m:
            i += 1
            continue

        name = m.group(1)
        params = [p.strip() for p in m.group(2).split(",") if p.strip()]
        body_lines: List[str] = []
        brace_balance = line.count("{") - line.count("}")
        inline_start = line.find("{")
        inline_end = line.rfind("}")
        if inline_start != -1 and inline_end > inline_start:
            inline_body = line[inline_start + 1 : inline_end].strip()
            if inline_body:
                body_lines.append(inline_body)
        j = i + 1
        while j < len(lines) and brace_balance > 0:
            _, body_line = lines[j]
            brace_balance += body_line.count("{") - body_line.count("}")
            body_lines.append(body_line)
            j += 1

        functions[name] = {
            "params": params,
            "body": "\n".join(body_lines),
            "line": lineno,
        }
        i = j
    return functions


def _find_calls(line: str) -> List[Tuple[str, str]]:
    """Return function calls as (name, args_string) on a line."""
    calls: List[Tuple[str, str]] = []
    for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", line):
        calls.append((m.group(1), m.group(2)))
    return calls


def _is_tainted_expr(expr: str, taint_tokens: List[str], tainted_vars: Set[str], suspicious_vars: re.Pattern[str]) -> bool:
    if any(tok in expr for tok in taint_tokens):
        return True
    if any(v in expr for v in tainted_vars):
        return True
    return bool(suspicious_vars.search(expr))


def _build_sink_function_param_indexes(
    source_records: List[Tuple[Path, str]],
    sink_pattern_template: str,
) -> Dict[str, Set[int]]:
    """Build cross-file function summaries: param indexes that flow to sinks."""
    out: Dict[str, Set[int]] = {}
    for _path, content in source_records:
        lines = _iter_code_lines(content)
        functions = _extract_js_functions(lines)
        for fname, meta in functions.items():
            params = [str(p).strip() for p in list(meta["params"])]  # type: ignore[index]
            body = str(meta["body"])
            for idx, param in enumerate(params):
                if re.search(sink_pattern_template.format(param=re.escape(param)), body):
                    out.setdefault(fname, set()).add(idx)
    return out


# ---------------------------------------------------------------------------
# Security Headers (WEB-SEC-*)
# ---------------------------------------------------------------------------

_HEADER_CHECKS = [
    {
        "id": "WEB-SEC-001",
        "patterns": [r"Content-Security-Policy", r"contentSecurityPolicy", r"\bcsp\b"],
        "summary": "Missing Content-Security-Policy header configuration",
        "fix": "Configure Content-Security-Policy header in your server/deployment config.",
        "reference": "OWASP Secure Headers — Content-Security-Policy",
        "severity": "FAIL",
    },
    {
        "id": "WEB-SEC-002",
        "patterns": [r"Strict-Transport-Security", r"strictTransportSecurity", r"\bhsts\b"],
        "summary": "Missing Strict-Transport-Security header",
        "fix": "Add Strict-Transport-Security header (e.g. max-age=31536000; includeSubDomains).",
        "reference": "OWASP Secure Headers — HSTS",
        "severity": "FAIL",
    },
    {
        "id": "WEB-SEC-003",
        "patterns": [r"X-Frame-Options", r"xFrameOptions", r"frameguard"],
        "summary": "Missing X-Frame-Options header",
        "fix": "Add X-Frame-Options: DENY (or SAMEORIGIN) header to prevent clickjacking.",
        "reference": "OWASP Secure Headers — X-Frame-Options",
        "severity": "WARN",
    },
    {
        "id": "WEB-SEC-004",
        "patterns": [r"X-Content-Type-Options", r"noSniff", r"xContentTypeOptions"],
        "summary": "Missing X-Content-Type-Options header",
        "fix": "Add X-Content-Type-Options: nosniff header to prevent MIME-type sniffing.",
        "reference": "OWASP Secure Headers — X-Content-Type-Options",
        "severity": "WARN",
    },
    {
        "id": "WEB-SEC-005",
        "patterns": [r"Referrer-Policy", r"referrerPolicy"],
        "summary": "Missing Referrer-Policy header",
        "fix": "Add Referrer-Policy header (e.g. strict-origin-when-cross-origin).",
        "reference": "OWASP Secure Headers — Referrer-Policy",
        "severity": "INFO",
    },
    {
        "id": "WEB-SEC-006",
        "patterns": [r"Permissions-Policy", r"permissionsPolicy", r"Feature-Policy"],
        "summary": "Missing Permissions-Policy header",
        "fix": "Add Permissions-Policy header to control browser feature access (camera, mic, geolocation).",
        "reference": "W3C Permissions-Policy",
        "severity": "INFO",
    },
]


def _check_security_headers(config_contents: str, source: str = "") -> List[Finding]:
    findings: List[Finding] = []
    for check in _HEADER_CHECKS:
        found = any(re.search(p, config_contents, re.IGNORECASE) for p in check["patterns"])
        if not found:
            findings.append(Finding(
                rule_id=check["id"],
                severity=check["severity"],
                message=check["summary"],
                fix=check["fix"],
                reference=check["reference"],
            ))

    cors_wildcard_patterns = [
        r'Access-Control-Allow-Origin["\s:]*\*',
        r'(?:allowOrigin|origin)\s*[:=]\s*["\']?\*["\']?',
        r'cors\s*\(\s*\)',
    ]
    cors_misconfigured = any(re.search(p, config_contents + source, re.IGNORECASE) for p in cors_wildcard_patterns)
    if cors_misconfigured:
        findings.append(Finding(
            rule_id="WEB-SEC-008",
            severity="WARN",
            message="CORS configured with wildcard origin (Access-Control-Allow-Origin: *)",
            fix="Restrict CORS to specific trusted origins instead of using wildcard (*).",
            reference="OWASP — CORS Misconfiguration",
        ))

    csp_content = ""
    csp_match = re.search(r'Content-Security-Policy["\s:]+([^\n"]+)', config_contents, re.IGNORECASE)
    if csp_match:
        csp_content = csp_match.group(1)
    csp_source_match = re.search(r'contentSecurityPolicy["\s:]+([^\n"]+)', config_contents + source, re.IGNORECASE)
    if csp_source_match:
        csp_content += " " + csp_source_match.group(1)
    if csp_content:
        unsafe_found = []
        if re.search(r"'unsafe-inline'", csp_content):
            unsafe_found.append("unsafe-inline")
        if re.search(r"'unsafe-eval'", csp_content):
            unsafe_found.append("unsafe-eval")
        if unsafe_found:
            findings.append(Finding(
                rule_id="WEB-SEC-009",
                severity="WARN",
                message=f"CSP contains {', '.join(unsafe_found)} — weakens XSS protection",
                fix="Remove 'unsafe-inline' and 'unsafe-eval' from CSP. Use nonces or hashes instead.",
                reference="MDN — Content-Security-Policy — unsafe-inline",
            ))

    rate_limit_patterns = [
        r"rate.?limit", r"rateLimit", r"express-rate-limit", r"RateLimiter",
        r"throttle", r"Throttle", r"slowDown", r"express-slow-down",
    ]
    combined = config_contents + source
    has_rate_limiting = any(re.search(p, combined, re.IGNORECASE) for p in rate_limit_patterns)
    if not has_rate_limiting and source:
        findings.append(Finding(
            rule_id="WEB-SEC-010",
            severity="INFO",
            message="No rate limiting or request throttling detected",
            fix="Add rate limiting middleware (e.g. express-rate-limit) to prevent abuse and DDoS.",
            reference="OWASP API Security — Rate Limiting",
        ))

    return findings


# ---------------------------------------------------------------------------
# OWASP Top 10 (WEB-OWASP-*)
# ---------------------------------------------------------------------------

def _check_owasp(root: Path, source: str, html: str, frameworks: List[str]) -> List[Finding]:
    findings: List[Finding] = []
    source_records = _collect_source_records(root)

    lockfiles = {
        "package-lock.json": "npm",
        "yarn.lock": "yarn",
        "pnpm-lock.yaml": "pnpm",
        "bun.lockb": "bun",
    }
    for lockfile, manager in lockfiles.items():
        if (root / lockfile).exists():
            vuln_count = _run_audit(root, manager)
            if vuln_count and vuln_count > 0:
                findings.append(Finding(
                    rule_id="WEB-OWASP-001",
                    severity="FAIL",
                    message=f"Known dependency vulnerabilities detected ({vuln_count} via {manager} audit)",
                    fix=f"Run `{manager} audit fix` or update vulnerable packages.",
                    reference="OWASP A06:2021 — Vulnerable and Outdated Components",
                ))
            break

    eval_patterns = [
        r"\beval\s*\(",
        r"new\s+Function\s*\(",
        r"setTimeout\s*\(\s*['\"`]",
        r"setInterval\s*\(\s*['\"`]",
    ]
    eval_hits: List[Tuple[str, int]] = []
    for path, content in source_records:
        rel = str(path.relative_to(root))
        for lineno, line in _iter_code_lines(content):
            if any(re.search(p, line) for p in eval_patterns):
                eval_hits.append((rel, lineno))
    if eval_hits:
        findings.append(Finding(
            rule_id="WEB-OWASP-002",
            severity="WARN",
            message=f"Unsafe eval() / dynamic execution usage found ({len(eval_hits)} occurrence(s))",
            fix="Remove eval() and new Function() calls. Use safer alternatives.",
            reference="OWASP A03:2021 — Injection",
            file_path=eval_hits[0][0],
            line=eval_hits[0][1],
        ))

    sink_patterns = [
        r"\.innerHTML\s*=\s*(.+)",
        r"\.outerHTML\s*=\s*(.+)",
        r"insertAdjacentHTML\s*\([^,]+,\s*(.+)\)",
        r"dangerouslySetInnerHTML\s*=\s*\{\s*\{[^}]*__html\s*:\s*([^}]+)\}\s*\}",
        r"document\.write\s*\((.+)\)",
    ]
    taint_tokens = [
        "req.query", "req.params", "req.body", "location.search", "window.location.search",
        "location.hash", "searchParams.get(", "params.get(", "document.cookie", "userInput",
    ]
    sanitizer_tokens = get_sanitizers(frameworks)
    suspicious_vars = re.compile(r"\b(userInput|input|rawHtml|unsafe|payload)\b", re.IGNORECASE)
    xss_hits: List[Tuple[str, int]] = []

    xss_sink_func_params = _build_sink_function_param_indexes(
        source_records,
        r"(innerHTML|outerHTML|insertAdjacentHTML|dangerouslySetInnerHTML|document\.write)[^\n]*\b{param}\b",
    )

    for path, content in source_records:
        rel = str(path.relative_to(root))
        lines = _iter_code_lines(content)
        tainted_vars: Set[str] = set()

        for _, line in lines:
            m = re.search(r"(?:const|let|var)?\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)", line)
            if m:
                var_name = m.group(1)
                rhs = m.group(2)
                if any(tok in rhs for tok in taint_tokens) or suspicious_vars.search(var_name):
                    tainted_vars.add(var_name)

        for idx, (lineno, line) in enumerate(lines):
            # Interprocedural: tainted arg passed to known sink-wrapper function.
            for callee, args in _find_calls(line):
                sink_indexes = xss_sink_func_params.get(callee, set())
                if not sink_indexes:
                    continue
                arg_list = [a.strip() for a in args.split(",")]
                for arg_idx, arg in enumerate(arg_list):
                    if arg_idx not in sink_indexes:
                        continue
                    if _is_tainted_expr(arg, taint_tokens, tainted_vars, suspicious_vars):
                        sanitized_call = any(tok in line for tok in sanitizer_tokens)
                        if not sanitized_call:
                            xss_hits.append((rel, lineno))

            expr = ""
            for pattern in sink_patterns:
                m = re.search(pattern, line)
                if m:
                    expr = m.group(1) if m.groups() else line
                    break
            if expr:
                context = " ".join(lines[i][1] for i in range(max(0, idx - 2), min(len(lines), idx + 3)))
                sanitized = any(tok in context for tok in sanitizer_tokens)
                tainted = _is_tainted_expr(expr, taint_tokens, tainted_vars, suspicious_vars)
                if not sanitized and tainted:
                    xss_hits.append((rel, lineno))

    if xss_hits:
        findings.append(Finding(
            rule_id="WEB-OWASP-003",
            severity="WARN",
            message=f"Potential XSS sink usage found ({len(xss_hits)} instance(s) of unsanitized HTML rendering)",
            fix="Use textContent instead of innerHTML, or sanitize input with DOMPurify before rendering.",
            reference="OWASP A03:2021 — Injection (Cross-Site Scripting)",
            file_path=xss_hits[0][0],
            line=xss_hits[0][1],
        ))

    insecure_hits: List[Tuple[str, int]] = []
    for path, content in source_records:
        rel = str(path.relative_to(root))
        lines = _iter_code_lines(content)
        for idx, (lineno, line) in enumerate(lines):
            lower = line.lower()
            if re.search(r"(httponly)\s*[:=]\s*false", lower):
                insecure_hits.append((rel, lineno))
                continue
            if re.search(r"(\bsecure)\s*[:=]\s*false", lower):
                insecure_hits.append((rel, lineno))
                continue
            if re.search(r"(samesite)\s*[:=]\s*[\"']?none[\"']?", lower):
                context = " ".join(lines[i][1].lower() for i in range(max(0, idx - 1), min(len(lines), idx + 2)))
                if "secure:true" not in context and "secure = true" not in context and "secure: true" not in context:
                    insecure_hits.append((rel, lineno))

    if insecure_hits:
        findings.append(Finding(
            rule_id="WEB-OWASP-004",
            severity="WARN",
            message=f"Insecure cookie/session configuration detected ({len(insecure_hits)} instance(s))",
            fix="Set httpOnly: true, secure: true, and sameSite: 'strict' or 'lax' on cookies.",
            reference="OWASP A07:2021 — Identification and Authentication Failures",
            file_path=insecure_hits[0][0],
            line=insecure_hits[0][1],
        ))

    external_scripts = re.findall(r'<script[^>]+src\s*=\s*["\']https?://[^"\']+["\'][^>]*>', html + source)
    for script in external_scripts:
        if "integrity=" not in script:
            findings.append(Finding(
                rule_id="WEB-OWASP-005",
                severity="INFO",
                message="External script loaded without Subresource Integrity (SRI) hash",
                fix="Add integrity and crossorigin attributes to external <script> tags.",
                reference="MDN — Subresource Integrity",
            ))
            break

    taint_redirect_tokens = [
        "req.query", "req.params", "req.body", "params.get(", "searchParams.get(",
        "location.search", "window.location.search", "location.hash",
    ]
    allowlist_tokens = get_redirect_allowlists(frameworks)
    open_redirect_hits: List[Tuple[str, int]] = []
    redirect_sink_func_params = _build_sink_function_param_indexes(
        source_records,
        r"(res\.redirect\s*\(|window\.location\s*=|location\.href\s*=)[^\n]*\b{param}\b",
    )

    for path, content in source_records:
        rel = str(path.relative_to(root))
        lines = _iter_code_lines(content)
        tainted_vars: Set[str] = set()
        for _, line in lines:
            m = re.search(r"(?:const|let|var)?\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)", line)
            if m:
                var_name = m.group(1)
                rhs = m.group(2)
                if any(tok in rhs for tok in taint_redirect_tokens):
                    tainted_vars.add(var_name)

        for idx, (lineno, line) in enumerate(lines):
            has_sink = bool(re.search(r"\bres\.redirect\s*\(|window\.location\s*=|location\.href\s*=", line))
            context = " ".join(lines[i][1] for i in range(max(0, idx - 3), min(len(lines), idx + 4)))
            tainted = any(tok in context for tok in taint_redirect_tokens)
            allowlisted = any(tok in context for tok in allowlist_tokens)
            interproc_tainted = False
            for callee, args in _find_calls(line):
                sink_indexes = redirect_sink_func_params.get(callee, set())
                if not sink_indexes:
                    continue
                arg_list = [a.strip() for a in args.split(",")]
                for arg_idx, arg in enumerate(arg_list):
                    if arg_idx not in sink_indexes:
                        continue
                    if any(tok in arg for tok in taint_redirect_tokens) or any(v in arg for v in tainted_vars):
                        interproc_tainted = True

            if (has_sink and tainted and not allowlisted) or (interproc_tainted and not allowlisted):
                open_redirect_hits.append((rel, lineno))

    if open_redirect_hits:
        findings.append(Finding(
            rule_id="WEB-OWASP-006",
            severity="WARN",
            message=f"Potential open redirect patterns found ({len(open_redirect_hits)} instance(s))",
            fix="Validate redirect URLs against an allowlist of trusted domains before redirecting.",
            reference="OWASP A01:2021 — Broken Access Control",
            file_path=open_redirect_hits[0][0],
            line=open_redirect_hits[0][1],
        ))

    cookie_hits: List[Tuple[str, int]] = []
    for path, content in source_records:
        rel = str(path.relative_to(root))
        for lineno, line in _iter_code_lines(content):
            if re.search(r"(setCookie|set-cookie|document\.cookie\s*=)", line, re.IGNORECASE):
                if not re.search(r"\bsecure\b", line, re.IGNORECASE):
                    cookie_hits.append((rel, lineno))

    if cookie_hits:
        findings.append(Finding(
            rule_id="WEB-OWASP-007",
            severity="WARN",
            message=f"Cookie set without Secure flag ({len(cookie_hits)} instance(s))",
            fix="Add the Secure flag to all cookies to ensure they are only sent over HTTPS.",
            reference="OWASP — Secure Cookie Attribute",
            file_path=cookie_hits[0][0],
            line=cookie_hits[0][1],
        ))

    weak_hash_patterns = [
        r'createHash\s*\(\s*["\'](?:md5|sha1)["\']',
        r'\bmd5\s*\(', r'\bsha1\s*\(',
        r'hashlib\.(?:md5|sha1)\s*\(',
        r'MessageDigest\.getInstance\s*\(\s*["\'](?:MD5|SHA-1)["\']',
    ]
    weak_hits: List[Tuple[str, int]] = []
    for path, content in source_records:
        rel = str(path.relative_to(root))
        for lineno, line in _iter_code_lines(content):
            if any(re.search(p, line) for p in weak_hash_patterns):
                weak_hits.append((rel, lineno))

    if weak_hits:
        findings.append(Finding(
            rule_id="WEB-OWASP-008",
            severity="WARN",
            message=f"Weak cryptographic hashing detected ({len(weak_hits)} instance(s) of MD5/SHA-1)",
            fix="Use bcrypt, scrypt, or Argon2 for password hashing. Use SHA-256+ for general hashing.",
            reference="OWASP A02:2021 — Cryptographic Failures",
            file_path=weak_hits[0][0],
            line=weak_hits[0][1],
        ))

    return findings


def _run_audit(root: Path, manager: str) -> Optional[int]:
    """Run a package manager audit and return vulnerability count, or None."""
    cmd_map = {
        "npm": ["npm", "audit", "--json"],
        "yarn": ["yarn", "npm", "audit", "--json"],
        "pnpm": ["pnpm", "audit", "--json"],
        "bun": ["bun", "pm", "audit", "--json"],
    }
    cmd = cmd_map.get(manager)
    if not cmd:
        return None
    try:
        result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=60)
        try:
            data = json.loads(result.stdout)
            if "metadata" in data:
                vuln_meta = data["metadata"].get("vulnerabilities", {})
                return sum(vuln_meta.values()) if isinstance(vuln_meta, dict) else 0
        except json.JSONDecodeError:
            pass
        if manager == "yarn":
            count = 0
            for line in result.stdout.splitlines():
                try:
                    entry = json.loads(line)
                    if entry.get("type") == "auditAdvisory":
                        count += 1
                except json.JSONDecodeError:
                    continue
            return count if count > 0 else None
    except (subprocess.TimeoutExpired, OSError, KeyError):
        pass
    return None


# ---------------------------------------------------------------------------
# Hardcoded Secrets (WEB-SEC-007)
# ---------------------------------------------------------------------------

_SECRET_PATTERNS = [
    (r'(?i)(?:api[_-]?key|apikey)\s*[:=]\s*["\'][A-Za-z0-9_\-]{16,}["\']', "API key"),
    (r'(?i)(?:secret|token|password|passwd)\s*[:=]\s*["\'][A-Za-z0-9_\-]{8,}["\']', "Secret/token"),
    (r'(?i)sk_(?:live|test)_[A-Za-z0-9]{24,}', "Stripe key"),
    (r'AIza[0-9A-Za-z_\-]{35}', "Google API key"),
    (r'(?i)aws[_-]?(?:access[_-]?key|secret)\s*[:=]\s*["\'][A-Za-z0-9/+=]{16,}["\']', "AWS credential"),
    (r'(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}', "GitHub token"),
    (r'github_pat_[A-Za-z0-9_]{20,}', "GitHub fine-grained token"),
]


def _check_secrets(source: str) -> List[Finding]:
    """Detect hardcoded secrets in source code."""
    findings: List[Finding] = []
    found_types: List[str] = []
    excluded_markers = ("oncecheck:allow-secret", "example only", "dummy", "placeholder")

    for line in source.splitlines():
        lower = line.lower()
        if any(marker in lower for marker in excluded_markers):
            continue
        for pattern, label in _SECRET_PATTERNS:
            if re.search(pattern, line):
                found_types.append(label)

    if found_types:
        uniq = list(dict.fromkeys(found_types))
        findings.append(Finding(
            rule_id="WEB-SEC-007",
            severity="FAIL",
            message=f"Hardcoded secrets detected in source code: {', '.join(uniq)}",
            fix="Move secrets to environment variables (.env) and ensure .env files are in .gitignore.",
            reference="OWASP A02:2021 — Cryptographic Failures",
        ))

    return findings


# ---------------------------------------------------------------------------
# Accessibility (WEB-A11Y-*)
# ---------------------------------------------------------------------------

def _check_accessibility(root: Path, content: str) -> List[Finding]:
    findings: List[Finding] = []

    img_no_alt = re.findall(r'<(?:img|Image)\b(?![^>]*\balt\s*=)[^>]*/?\s*>', content)
    if img_no_alt:
        findings.append(Finding(
            rule_id="WEB-A11Y-001",
            severity="WARN",
            message=f"Images missing alt text ({len(img_no_alt)} instance(s))",
            fix="Add descriptive alt attributes to all <img> and <Image> elements.",
            reference="WCAG 2.1 — 1.1.1 Non-text Content",
        ))

    click_patterns = [
        r'<(?:div|span)\b[^>]*\bonClick\b',
        r'<(?:div|span)\b[^>]*\bonclick\b',
        r'<(?:div|span)\b[^>]*\b@click\b',
        r'<(?:div|span)\b[^>]*\bon:click\b',
        r'<(?:div|span)\b[^>]*\b\(click\)\s*=',
    ]
    bad_click = sum(len(re.findall(p, content)) for p in click_patterns)
    if bad_click:
        findings.append(Finding(
            rule_id="WEB-A11Y-002",
            severity="WARN",
            message=f"Non-semantic elements with click handlers ({bad_click} instance(s))",
            fix="Use semantic elements (<button>, <a>) instead of <div>/<span> with click handlers.",
            reference="WCAG 2.2 — 2.1.1 Keyboard",
        ))

    focus_outline_none = re.findall(r'outline\s*:\s*(?:none|0)\b', content)
    if focus_outline_none:
        findings.append(Finding(
            rule_id="WEB-A11Y-003",
            severity="WARN",
            message=f"Focus outline removed ({len(focus_outline_none)} instance(s) of outline: none)",
            fix="Provide visible focus indicators instead of removing outlines. Use :focus-visible for custom styles.",
            reference="WCAG 2.1 — 2.4.7 Focus Visible",
        ))

    inputs_no_label = re.findall(r'<input\b(?![^>]*(?:\baria-label\b|\baria-labelledby\b|\bid\s*=))[^>]*/?\s*>', content)
    if inputs_no_label:
        findings.append(Finding(
            rule_id="WEB-A11Y-004",
            severity="WARN",
            message=f"Form inputs potentially missing labels ({len(inputs_no_label)} instance(s))",
            fix="Associate each <input> with a <label> element or add aria-label attribute.",
            reference="WCAG 2.1 — 1.3.1 Info and Relationships",
        ))

    has_html_lang = bool(re.search(r'<html[^>]*\blang\s*=', content))
    has_html_tag = bool(re.search(r'<html\b', content))
    if has_html_tag and not has_html_lang:
        findings.append(Finding(
            rule_id="WEB-A11Y-005",
            severity="WARN",
            message="HTML document missing lang attribute",
            fix='Add lang attribute to <html> element (e.g. <html lang="en">).',
            reference="WCAG 2.1 — 3.1.1 Language of Page",
        ))

    return findings


# ---------------------------------------------------------------------------
# Privacy (WEB-PRIV-*)
# ---------------------------------------------------------------------------

_TRACKING_PATTERNS = [
    r"google-analytics", r"googletagmanager", r"\bgtag\b",
    r"fbevents", r"facebook.*pixel", r"hotjar", r"mixpanel", r"segment",
]
_CONSENT_PATTERNS = [
    r"cookie-consent", r"cookieConsent", r"CookieBanner", r"cookie-banner",
    r"\bgdpr\b", r"OneTrust", r"cookiebot",
]


def _check_privacy(root: Path, content: str, config: str) -> List[Finding]:
    findings: List[Finding] = []

    has_tracking = any(re.search(p, content, re.IGNORECASE) for p in _TRACKING_PATTERNS)
    has_consent = any(re.search(p, content, re.IGNORECASE) for p in _CONSENT_PATTERNS)

    if has_tracking and not has_consent:
        findings.append(Finding(
            rule_id="WEB-PRIV-001",
            severity="WARN",
            message="Tracking scripts detected but no cookie consent mechanism found",
            fix="Implement a cookie consent banner/manager (required by GDPR/CCPA).",
            reference="GDPR Article 7 — Conditions for consent",
        ))

    privacy_files = list(root.rglob("privacy*")) + list(root.rglob("privacy-policy*"))
    privacy_files = [f for f in privacy_files if "node_modules" not in str(f)]
    privacy_ref = bool(re.search(r'/privacy|privacy[.\-]policy', content, re.IGNORECASE))
    if not privacy_files and not privacy_ref:
        findings.append(Finding(
            rule_id="WEB-PRIV-002",
            severity="FAIL",
            message="No privacy policy page or reference found",
            fix="Add a privacy policy page to your site (required if collecting any user data).",
            reference="GDPR / CCPA / CalOPPA requirements",
        ))

    if has_consent:
        consent_record_patterns = [
            r"consent.*log|logConsent|recordConsent|consentRecord",
            r"localStorage.*consent|sessionStorage.*consent",
            r"setCookie.*consent",
        ]
        records_consent = any(re.search(p, content, re.IGNORECASE) for p in consent_record_patterns)
        if not records_consent:
            findings.append(Finding(
                rule_id="WEB-PRIV-003",
                severity="INFO",
                message="Cookie consent mechanism found but no consent recording/logging detected",
                fix="Record and store user consent choices for audit trail compliance.",
                reference="GDPR Article 7(1) — Demonstrating Consent",
            ))

    if has_tracking:
        ccpa_patterns = [r"do.not.sell", r"doNotSell", r"ccpa", r"opt.out", r"optOut"]
        has_ccpa = any(re.search(p, content, re.IGNORECASE) for p in ccpa_patterns)
        if not has_ccpa:
            findings.append(Finding(
                rule_id="WEB-PRIV-004",
                severity="INFO",
                message="No 'Do Not Sell' or CCPA opt-out mechanism found",
                fix="Add a 'Do Not Sell My Personal Information' link if serving California users.",
                reference="CCPA / CPRA Requirements",
            ))

    tos_files = list(root.rglob("terms*"))
    tos_files = [f for f in tos_files if "node_modules" not in str(f)]
    tos_ref = bool(re.search(r'/terms|terms[.\-]of[.\-]service|terms-and-conditions', content, re.IGNORECASE))
    if not tos_files and not tos_ref:
        findings.append(Finding(
            rule_id="WEB-PRIV-005",
            severity="INFO",
            message="No Terms of Service page or reference found",
            fix="Add a Terms of Service page to your site.",
            reference="Legal best practices for web applications",
        ))

    return findings
