"""LLM-guided security scanning tools for MCP server."""

import hashlib
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4

from mcp.types import TextContent, Tool

from maeris_mcp.scanners.code_reader import collect_scan_files, get_file_contents
from maeris_mcp.scanners.dep_scanner import scan_dependencies
from maeris_mcp.scanners.dockerfile_scanner import scan_dockerfile_secrets
from maeris_mcp.scanners.jsx_scanner import scan_sensitive_ui_exposure
from maeris_mcp.scanners.pattern_scanner import scan_patterns
from maeris_mcp.scanners.secret_scanner import scan_source_secrets
from maeris_mcp.scanners.security_checks import (
    get_checks_and_profiles,
    load_checks,
    is_security_profile,
    is_gigw_profile,
    is_a11y_profile,
    is_compliance_profile,
    classify_gigw_check,
    build_check_section_index,
    GIGW_SECTION_TO_PILLAR,
    _GIGW_SECTIONS,
    _A11Y_SECTIONS,
)
from maeris_mcp.storage.security_store import (
    SecurityScanStore,
    load_file_hashes,
    load_persistent_findings,
    save_scan_cache,
)
from maeris_mcp.types.security import (
    OWASP_DISPLAY_NAMES,
    CheckResult,
    Confidence,
    OWASPCategory,
    ScanSummary,
    SecurityFinding,
    SecurityScanResult,
    Severity,
    SeverityCounts,
    StoredSecurityScan,
)


def security_scan_tool() -> Tool:
    """Return the tool definition for security_scan."""
    return Tool(
        name="security_scan",
        description=(
            "LLM-guided static security scan. "
            "Use this tool when the user asks to run OR set up a repository security scan "
            "(e.g., 'setup repository security scan', 'scan for vulnerabilities', 'check API security'). "
            "Fetches file batches and accepts findings in the same call. "
            "Pass findings=[...] alongside offset to submit findings from the previous batch while fetching the next. "
            "Set is_last=true on the final call to finalize the scan and receive the full report.\n\n"
            "PROFILE SELECTION — choose scan_profile based on what the user asked for.\n"
            "scan_profile can be a single string OR an array of strings to combine multiple profiles in one scan.\n\n"
            "- 'security scan' / 'scan for vulnerabilities' / 'find security issues' → 'owasp_top10_static'\n"
            "- 'security audit' / 'deep security scan' / 'thorough security' / 'deep level security' / 'comprehensive security' / 'full security audit' → 'deep_security'\n"
            "- 'check API security' / 'API vulnerabilities' → 'api_static'\n"
            "- 'check for secrets' / 'hardcoded credentials' / 'SAST scan' → 'sast_static'\n"
            "- 'authentication security' / 'check auth' / 'session security' → 'auth_session_static'\n"
            "- 'data exposure' / 'PII leak' / 'sensitive data' → 'data_exposure_static'\n"
            "- 'infrastructure security' / 'container security' / 'Dockerfile' / 'K8s' / 'cloud security' → 'infra_static'\n"
            "- 'full audit' / 'comprehensive scan' / 'everything' → 'all_static'\n"
            "- 'accessibility check' / 'WCAG audit' / 'a11y' → 'general_accessibility'\n"
            "- 'WCAG 2.1' → 'wcag_21_aa'\n"
            "- 'WCAG 2.2' → 'wcag_22'\n"
            "- 'GIGW' / 'government website' / 'NIC' / 'gov.in' compliance → 'gigw_compliance'\n"
            "- 'GIGW security' → 'gigw_code_security'\n"
            "- 'GIGW accessibility' → 'gigw_accessibility'\n"
            "- Multiple categories mentioned → combine as array, e.g. ['deep_security', 'gigw_compliance', 'general_accessibility']\n"
            "- No profile mentioned → default to 'owasp_top10_static' (security scan, no accessibility/GIGW).\n\n"
            "Examples:\n"
            "- 'run a security scan' → 'owasp_top10_static'\n"
            "- 'run a deep security scan' → 'deep_security'\n"
            "- 'deep level security scan, GIGW and accessibility' → ['deep_security', 'gigw_compliance', 'general_accessibility']\n"
            "- 'security scan, GIGW and accessibility' → ['owasp_top10_static', 'gigw_compliance', 'general_accessibility']\n"
            "- 'scan everything' → 'all_static'\n"
            "- 'security and API scan' → ['owasp_top10_static', 'api_static']"
        ),
        inputSchema={
            "type": "object",
            "properties": {
                "target_path": {
                    "type": "string",
                    "description": "Absolute file or directory path to scan (optional; defaults to server working directory)",
                },
                "scan_id": {
                    "type": "string",
                    "description": "Existing scan session ID to continue/paginate",
                },
                "scan_profile": {
                    "oneOf": [
                        {"type": "string"},
                        {"type": "array", "items": {"type": "string"}},
                    ],
                    "description": (
                        "Scan profile(s) to use. Can be a single string or an array of strings to combine multiple profiles. "
                        "Default: owasp_top10_static (security vulnerabilities only). "
                        "Examples: 'owasp_top10_static', ['owasp_top10_static', 'gigw_compliance', 'general_accessibility']. "
                        "Use all_static for a full audit including accessibility and compliance. "
                        "Use gigw_compliance only when the user explicitly mentions government/GIGW/NIC. "
                        "Use general_accessibility / wcag_21_aa only when the user explicitly asks for accessibility."
                    ),
                },
                "checks": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Explicit check IDs to use (overrides scan_profile)",
                },
                "include_content": {
                    "type": "boolean",
                    "description": "Include file contents for analysis. Default: false",
                    "default": False,
                },
                "offset": {
                    "type": "integer",
                    "description": "Skip first N files when including content. Default: 0",
                    "default": 0,
                },
                "limit": {
                    "type": "integer",
                    "description": "Max files to return content for (1-10). Default: 5",
                    "default": 5,
                },
                "findings": {
                    "type": "array",
                    "items": {"type": "object"},
                    "description": "Findings from analyzing the previous file batch. Submit alongside the next batch offset.",
                },
                "is_last": {
                    "type": "boolean",
                    "description": "Set true on the final call to finalize the scan and get the full report.",
                },
                "checkResults": {
                    "type": "array",
                    "description": (
                        "REQUIRED on the FINAL call (is_last=true) for GIGW and accessibility scans. "
                        "Report the pass/fail verdict for EVERY GIGW and WCAG check in selectedChecks."
                    ),
                    "items": {
                        "type": "object",
                        "properties": {
                            "checkId": {"type": "string"},
                            "status": {"type": "string", "enum": ["pass", "fail", "not_applicable"]},
                            "notes": {"type": "string"},
                        },
                        "required": ["checkId", "status"],
                    },
                },
            },
        },
    )


def list_security_checks_tool() -> Tool:
    """Return the tool definition for list_security_checks."""
    return Tool(
        name="list_security_checks",
        description=(
            "Lists available static-feasible security checks and scan profiles. "
            "Use this to decide which checks to run before calling security_scan."
        ),
        inputSchema={"type": "object", "properties": {}},
    )


def submit_security_findings_tool() -> Tool:
    """Return the tool definition for submit_security_findings (kept for backward compat)."""
    return Tool(
        name="submit_security_findings",
        description=(
            "Submit LLM findings for a scan in batches. "
            "Set is_last=true on the final batch to finalize the scan."
        ),
        inputSchema={
            "type": "object",
            "properties": {
                "scan_id": {"type": "string", "description": "Scan session ID"},
                "findings": {"type": "array", "items": {"type": "object"}},
                "is_last": {"type": "boolean", "description": "Finalize when true"},
            },
            "required": ["scan_id", "findings"],
        },
    )


def get_security_scans_tool() -> Tool:
    """Return the tool definition for get_security_scans."""
    return Tool(
        name="get_security_scans",
        description="Retrieves stored security scan results with full findings.",
        inputSchema={
            "type": "object",
            "properties": {
                "scan_id": {
                    "type": "string",
                    "description": "Specific scan ID to retrieve.",
                },
                "last_n": {
                    "type": "integer",
                    "description": "Return the last N scans (most recent first). E.g. last_n=3 for last 3 scans.",
                },
                "nth": {
                    "type": "integer",
                    "description": "Return the Nth most recent scan (1-indexed). 1=last, 2=second last, etc.",
                },
            },
        },
    )


def clear_security_scans_tool() -> Tool:
    """Return the tool definition for clear_security_scans."""
    return Tool(
        name="clear_security_scans",
        description="Clears stored security scans. Can clear all scans or a specific scan by ID.",
        inputSchema={
            "type": "object",
            "properties": {
                "scan_id": {
                    "type": "string",
                    "description": "Optional: specific scan ID to delete. If not provided, clears all.",
                },
            },
        },
    )


def _get_project_paths(target_path: str) -> tuple[Path, str]:
    """Return base for relative paths and display name."""
    path = Path(target_path)
    project_root = path if path.is_dir() else path.parent
    base_for_rel = project_root.parent if project_root.parent != project_root else project_root
    display_name = project_root.name or str(project_root)
    return base_for_rel, display_name


def _expected_output_schema() -> dict[str, Any]:
    return {
        "type": "object",
        "properties": {
            "findings": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "id": {"type": "string"},
                        "ruleId": {"type": "string"},
                        "title": {
                            "type": "string",
                            "description": (
                                "A verbose title including the full category name. "
                                "Security: 'OWASP A01:2021 (Broken Access Control): <specific finding>'. "
                                "GIGW: 'GIGW-SEC (GIGW Code Security): <specific finding>'. "
                                "Accessibility: 'WCAG-AA (WCAG Level AA): <specific finding>'."
                            ),
                        },
                        "description": {"type": "string"},
                        "severity": {
                            "type": "string",
                            "enum": ["low", "medium", "high", "critical"],
                        },
                        "confidence": {
                            "type": "string",
                            "enum": ["low", "medium", "high"],
                        },
                        "owaspCategory": {
                            "type": "string",
                            "enum": [
                                "A01", "A02", "A03", "A04", "A05",
                                "A06", "A07", "A08", "A09", "A10",
                                "GIGW-SEC", "GIGW-A11Y", "GIGW-QUAL", "GIGW-LIFE", "GIGW-GOV",
                                "WCAG-A", "WCAG-AA", "WCAG-AAA",
                            ],
                            "description": (
                                "Category code. Security: A01-A10. "
                                "GIGW: GIGW-SEC, GIGW-A11Y, GIGW-QUAL, GIGW-LIFE, GIGW-GOV. "
                                "Accessibility: WCAG-A, WCAG-AA, WCAG-AAA."
                            ),
                        },
                        "cweId": {"type": "string"},
                        "filePath": {
                            "type": "string",
                            "description": "The exact relative file path as shown in the fileContents keys. MUST NOT be empty.",
                        },
                        "lineStart": {
                            "type": "integer",
                            "description": "The exact line number from the line-numbered file content where the vulnerability starts. MUST NOT be 0.",
                        },
                        "lineEnd": {"type": "integer"},
                        "codeSnippet": {"type": "string"},
                        "recommendation": {
                            "type": "string",
                            "description": (
                                "Actionable fix guidance. MUST include one of: "
                                "(1) a corrected code snippet that resolves the vulnerability, "
                                "(2) step-by-step remediation instructions, or "
                                "(3) links to relevant documentation/advisories. "
                                "Be specific to the codebase — reference actual file paths, "
                                "function names, and framework APIs."
                            ),
                        },
                    },

                    "required": ["title", "filePath", "severity", "description", "lineStart", "codeSnippet", "recommendation"],
                },
            },
            "isLast": {"type": "boolean"},
            "checkResults": {
                "type": "array",
                "description": (
                    "REQUIRED on the FINAL call (is_last=true) for GIGW and accessibility scans. "
                    "Report the pass/fail verdict for EVERY GIGW and WCAG check in selectedChecks. "
                    "This is how the compliance scorecard is calculated — do NOT skip checks."
                ),
                "items": {
                    "type": "object",
                    "properties": {
                        "checkId": {
                            "type": "string",
                            "description": "The exact check ID from selectedChecks",
                        },
                        "status": {
                            "type": "string",
                            "enum": ["pass", "fail", "not_applicable"],
                            "description": (
                                "pass = code meets this check's requirement. "
                                "fail = code violates this check (must have a corresponding finding). "
                                "not_applicable = check doesn't apply to this codebase (e.g., no Dockerfile for container checks)."
                            ),
                        },
                        "notes": {
                            "type": "string",
                            "description": "Brief explanation of why this check passed, failed, or is N/A",
                        },
                    },
                    "required": ["checkId", "status"],
                },
            },
        },
        "required": ["findings"],
    }


def _file_type_security_checklist(file_path: str) -> list[str]:  # noqa: C901
    """Return targeted, file-role-specific security questions for this file.

    Covers every scan profile: OWASP Top 10, OWASP API Top 10, SAST, Auth/Session,
    Data Exposure & PII, Business Logic, Cloud/Infra, Container & DevOps, GIGW,
    and WCAG Accessibility.

    Gives the LLM a concrete checklist to answer rather than an open-ended search,
    reducing stochastic variation and ensuring consistent coverage across runs.
    Returns an empty list for file types with no specific guidance.
    """
    norm = file_path.replace("\\", "/").lower()
    name = norm.rsplit("/", 1)[-1]
    ext = ("." + name.rsplit(".", 1)[-1]) if "." in name else ""

    # --- Role detection ---
    is_auth = any(s in norm for s in (
        "auth", "login", "logout", "signup", "register", "password",
        "token", "session", "jwt", "oauth", "sso", "2fa", "mfa", "credential",
    ))
    is_route = (
        any(s in norm for s in ("route", "controller", "handler", "view", "endpoint", "api/", "server", "app"))
        and ext in {".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".rb", ".go", ".java", ".php", ".cs"}
    )
    is_middleware = any(s in norm for s in ("middleware", "guard", "interceptor", "hook", "filter", "plugin"))
    is_model = any(s in norm for s in ("model", "schema", "entity", "orm", "repository", "dao", "serializer", "dto"))
    is_config = (
        any(s in norm for s in ("config", "settings", "secrets", "constants", "env", "options", "parameters"))
        or ext in {".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".properties", ".env"}
    ) and "docker-compose" not in norm and ".github" not in norm
    is_frontend = ext in {".jsx", ".tsx", ".vue", ".svelte"}
    is_html = ext in {".html", ".htm", ".jinja", ".jinja2", ".j2", ".hbs", ".ejs", ".pug", ".erb"}
    is_js_ts = ext in {".js", ".ts", ".mjs", ".cjs"}
    is_python = ext == ".py"
    is_dockerfile = "dockerfile" in name
    is_compose = "docker-compose" in name
    is_cicd = (
        ".github/workflows" in norm or "jenkinsfile" in name
        or ".circleci" in norm or ".gitlab-ci" in norm
        or "azure-pipelines" in name or "bitbucket-pipelines" in name
        or ".travis" in name or "cloudbuild" in name
    )
    is_k8s = (
        ext in {".yaml", ".yml"}
        and any(s in norm for s in ("k8s", "kubernetes", "kube", "deployment", "service", "ingress", "rbac", "clusterrole"))
    )
    is_terraform = ext in {".tf", ".tfvars"} or "terraform" in norm
    is_iam = any(s in norm for s in ("iam", "policy", "permission", "role", "trust")) and ext in {".json", ".yaml", ".yml", ".tf"}
    is_logging = any(s in norm for s in ("log", "logger", "logging", "audit", "monitor", "telemetry", "trace"))
    is_manifest = name in {"package.json", "requirements.txt", "requirements-dev.txt", "pipfile", "gemfile", "go.mod", "pom.xml", "build.gradle", "cargo.toml"}
    is_gigw = any(s in norm for s in ("gigw", "gov.in", "nic.in", "india.gov"))

    questions: list[str] = []

    # ------------------------------------------------------------------ #
    # A07: Authentication & Authorization Security                         #
    # ------------------------------------------------------------------ #
    if is_auth:
        questions += [
            # A07 — auth fundamentals
            "Are passwords hashed with bcrypt or argon2 (not md5, sha1, or plain sha256)?",
            "Is there rate limiting or CAPTCHA on login to prevent brute-force attacks?",
            "Is account lockout or exponential backoff applied after repeated failures?",
            # A07 — account enumeration
            "Do error messages avoid revealing whether the username or password was wrong?",
            # A07 — session / token lifecycle
            "Is the session ID rotated immediately after login (prevents session fixation)?",
            "Are sessions and tokens fully invalidated on logout and password change?",
            "Are JWT tokens validated for exp, iss, and aud claims, with the algorithm pinned (not 'none')?",
            # A04 — password reset / MFA
            "Are password reset tokens single-use, short-lived (<1h), and bound to the requesting user?",
            "Is MFA or step-up authentication enforced for admin actions and sensitive operations?",
            # OAuth
            "Is the OAuth state parameter validated on the callback (CSRF protection)?",
            "Are OAuth redirect URIs strictly validated — no wildcards or open redirectors?",
            "Is PKCE enforced for public OAuth clients (SPAs, mobile apps)?",
            # A09 — audit logging
            "Are login successes, failures, and logouts written to an audit log?",
        ]

    # ------------------------------------------------------------------ #
    # A01 + API Top 10: Access Control & API Route Security                #
    # ------------------------------------------------------------------ #
    if is_route or is_middleware:
        questions += [
            # A01 — authentication gate
            "Is authentication required on every endpoint that reads or modifies data?",
            # API1 — BOLA / IDOR
            "Is there an ownership check (BOLA) for every endpoint that fetches a resource by ID?",
            "Is user/tenant ID derived from the auth token — never from the request body or URL param?",
            # A01 — vertical privilege escalation
            "Are admin-only endpoints gated behind an explicit role/permission check?",
            # A01 — CSRF
            "Is CSRF protection (token or SameSite cookie) applied to all state-changing endpoints?",
            # A03 — injection at the route level
            "Is all user input validated and sanitized before reaching a database, file system, or shell?",
            # A04 — rate limiting
            "Is rate limiting applied to authentication, password reset, and file-upload endpoints?",
            # API3 — excessive data exposure
            "Does the API serialize only explicitly allowlisted fields, not the full model/object?",
            # API5 / BFLA
            "Are function-level permissions (admin actions, bulk operations) checked in every handler?",
            # A05 — error disclosure
            "Do error responses avoid leaking stack traces, SQL errors, or internal file paths?",
            # A10 — SSRF
            "Are user-supplied URLs validated against an allowlist before being used in HTTP requests (SSRF)?",
            # File upload
            "Are file upload endpoints validating content type, file extension, size, and storage path?",
            # A09 — audit trail
            "Are critical data changes and admin actions logged with the user ID and timestamp?",
        ]

    # ------------------------------------------------------------------ #
    # A03 + SAST: Models, ORM, Serializers                                 #
    # ------------------------------------------------------------------ #
    if is_model:
        questions += [
            # SQL injection
            "Are there raw SQL queries using f-strings, % formatting, or string concatenation?",
            "Are ORM raw() or execute() methods called with unparameterized user input?",
            # NoSQL injection
            "Are MongoDB/NoSQL query objects built directly from request body fields without sanitization?",
            # API6 — mass assignment
            "Is there an explicit allowlist of writable fields (not wildcard binding from request body)?",
            # API3 — excessive exposure
            "Are sensitive fields (password_hash, token, secret, internal_id) excluded from serialization?",
            # Data exposure
            "Do API responses expose raw database IDs, email addresses, or phone numbers unnecessarily?",
            # Unsafe crypto
            "Are MD5, SHA1, or DES used for any security-sensitive purpose (password hashing, signatures)?",
        ]

    # ------------------------------------------------------------------ #
    # A05 + A02: Configuration Security                                    #
    # ------------------------------------------------------------------ #
    if is_config:
        questions += [
            # A02 — hardcoded secrets
            "Are any credentials, API keys, tokens, or secrets hardcoded (not from env vars or secrets manager)?",
            "Are database credentials hardcoded instead of loaded at runtime?",
            # A05 — debug
            "Is DEBUG mode disabled (or strictly env-gated) for production deployments?",
            "Are verbose error pages or stack traces disabled in production?",
            # A05 — CORS
            "Are CORS origins restricted to known domains (not wildcard *)? If credentials are sent, wildcard is forbidden.",
            # A02 — security headers
            "Are security headers configured: HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy?",
            # A02 — TLS
            "Is the minimum TLS version set to 1.2+ (SSLv2, SSLv3, TLS 1.0/1.1 disabled)?",
            "Are database and cache connections using TLS/SSL?",
            # A02 — cookies
            "Are session cookies set with Secure, HttpOnly, and SameSite=Strict or Lax?",
            # A05 — directory listing / server info
            "Is directory listing disabled on web server configuration?",
            "Are server version banners and X-Powered-By headers suppressed?",
        ]

    # ------------------------------------------------------------------ #
    # A02 + A03 + A09 + WCAG: Frontend Components (JSX/TSX/Vue/Svelte)    #
    # ------------------------------------------------------------------ #
    if is_frontend:
        questions += [
            # A02 — sensitive data rendered in UI
            "Are password, token, secret, or API key fields rendered as visible text in the UI?",
            "Does any component receive a user object from the server that includes a password field?",
            # A03 — XSS
            "Are dangerouslySetInnerHTML, v-html, or innerHTML used with user-controlled or DB-sourced content?",
            "Are there document.write() or eval() calls with dynamic content?",
            # A02 — insecure storage
            "Is sensitive data (tokens, PII, secrets) stored in localStorage or sessionStorage (XSS-accessible)?",
            # A02 — hardcoded secrets
            "Are API keys, client secrets, or service tokens hardcoded in frontend source?",
            # A02 — mixed content / integrity
            "Are third-party CDN scripts loaded without integrity= and crossorigin= attributes (SRI missing)?",
            "Are there http:// (non-HTTPS) asset or API references that would cause mixed content?",
            # Form security
            "Are password and sensitive form inputs using type='password' to mask the value?",
            "Are password inputs missing autocomplete='new-password' or 'current-password'?",
            # WCAG 2.1 AA — images
            "Do all <img> and icon elements have meaningful alt text (not empty or generic 'image')?",
            # WCAG 2.1 — forms
            "Do all form inputs have an associated <label> element (not just placeholder text)?",
            "Are form validation errors described in text — not only indicated by color or icon?",
            "Do error messages tell the user how to correct the input?",
            # WCAG 2.1 — keyboard & focus
            "Is keyboard focus indicator visible on all interactive elements?",
            "Is keyboard focus ever trapped inside a modal or widget without a way to escape?",
            # WCAG 2.1 — ARIA
            "Do interactive elements (buttons, links, custom controls) have accessible name, role, and state?",
            "Are ARIA roles/states/properties used correctly and only on semantically appropriate elements?",
            # WCAG 2.1 — navigation
            "Is a skip-to-content link provided for keyboard users?",
            # WCAG 2.1 — contrast & color
            "Does text meet a minimum contrast ratio of 4.5:1 (or 3:1 for large text)?",
            "Is color the sole visual means of conveying information (requires additional indicator)?",
            # WCAG 2.1 — auto-play / motion
            "Does auto-playing audio or video provide pause/stop controls?",
            "Does any content flash more than three times per second?",
            # WCAG 2.2 — new success criteria (superset of 2.1 AA)
            "Does the keyboard focus indicator meet minimum size and contrast requirements — not just the default browser outline? (WCAG 2.4.11)",
            "Do drag-and-drop interactions have a single-pointer alternative (e.g., click-to-pick-up + click-to-drop)? (WCAG 2.5.7)",
            "Are all interactive targets at least 24×24 CSS pixels, or is there sufficient spacing around smaller targets? (WCAG 2.5.8)",
            "Do help mechanisms (chat widget, contact link, FAQ) appear in the same location on every page? (WCAG 3.2.6)",
            "Are users required to re-type information they already entered earlier in the same session? (WCAG 3.3.7 — redundant entry)",
            "Does authentication require solving a cognitive puzzle (image CAPTCHA) without providing an accessible alternative (email code, passkey)? (WCAG 3.3.8)",
            # SaaS / component-level accessibility patterns
            "Do modal dialogs implement the full ARIA dialog pattern: role=dialog, aria-modal=true, aria-labelledby, focus trapped inside?",
            "Are toast and snackbar notifications announced to screen readers via role=status or aria-live?",
            "Are interactive data tables fully keyboard navigable with accessible sort, filter, and pagination controls?",
            "Does infinite scroll provide a keyboard-accessible 'Load more' button as an alternative to automatic loading?",
            "Do autocomplete and combobox widgets follow the ARIA combobox pattern (role=combobox, aria-expanded, aria-activedescendant)?",
            "Do custom date pickers provide a keyboard-operable interface or a plain text input fallback?",
        ]

    # ------------------------------------------------------------------ #
    # WCAG 2.1 AA: HTML templates                                          #
    # ------------------------------------------------------------------ #
    if is_html:
        questions += [
            "Does the <html> element declare a lang attribute (e.g., lang='en')?",
            "Does every page have a unique, descriptive <title> element?",
            "Are heading levels (H1–H6) used in correct hierarchical order without skipping?",
            "Do all images have meaningful alt text?",
            "Do all form inputs have an associated <label>?",
            "Are links descriptive — no bare 'click here' or 'read more' text without context?",
            "Is there a skip-to-content link at the top of every page?",
            "Do data tables have <th> header cells with scope attribute?",
            "Do iframes have a descriptive title attribute?",
            "Is the page usable at 320px width without horizontal scrolling (WCAG 1.4.10)?",
            "Are ARIA live regions used for dynamically updated status messages?",
            "Are there http:// (non-HTTPS) asset references that would cause mixed content warnings?",
            "Are third-party scripts missing SRI integrity= attributes?",
            # WCAG 2.2 additions for HTML templates
            "Does the focus indicator meet minimum size and contrast (not relying solely on default browser outline)? (WCAG 2.4.11)",
            "Are all interactive targets at least 24×24 CSS pixels or have adequate spacing? (WCAG 2.5.8)",
            "Does authentication avoid cognitive-only tests without an accessible alternative? (WCAG 3.3.8)",
            "Do help links / contact mechanisms appear in the same location across all pages? (WCAG 3.2.6)",
        ]

    # ------------------------------------------------------------------ #
    # A03 + A10 + SAST: Server-side JavaScript / TypeScript                #
    # ------------------------------------------------------------------ #
    if is_js_ts and not is_frontend:
        questions += [
            # Dangerous APIs
            "Are eval(), Function(), or vm.runInNewContext() called with user-controlled data?",
            "Are child_process.exec() or spawn({shell:true}) used with user input (command injection)?",
            # Prototype pollution
            "Is Object.assign, lodash merge, or object spread used with user-supplied objects (prototype pollution)?",
            # Path traversal
            "Are file system paths (fs.readFile, fs.writeFile, path.join) built from user input without sanitization?",
            # A10 — SSRF
            "Are dynamic URLs from user input passed to fetch(), axios, got, or node:http without allowlist validation?",
            # Unsafe deserialization
            "Are node-serialize, serialize-javascript, or eval-based deserializers used with untrusted data?",
            # Injection
            "Are template literals used to construct SQL, shell commands, or LDAP filters with user data?",
            # SAST — crypto
            "Are MD5 or SHA1 used for security-sensitive operations (password hashing, HMAC signatures)?",
        ]

    # ------------------------------------------------------------------ #
    # A03 + A10 + SAST: Python files                                       #
    # ------------------------------------------------------------------ #
    if is_python:
        questions += [
            # Command injection
            "Are os.system(), subprocess.run(shell=True), or os.popen() called with user-controlled data?",
            # SQL injection
            "Are SQL queries built with f-strings, %-formatting, or .format() instead of parameterized queries?",
            # Unsafe deserialization
            "Are pickle.loads(), marshal.loads(), or yaml.load() (not yaml.safe_load) used with untrusted input?",
            # SSTI
            "Is render_template_string(), jinja2.Template(), or Mako render() called with user-supplied template strings?",
            # XXE
            "Is xml.etree.ElementTree, lxml, or defusedxml parsing untrusted XML? Is external entity processing disabled?",
            # Open redirect
            "Is redirect() called with request.args.get('next') or similar without URL scheme/host validation?",
            # A10 — SSRF
            "Are requests.get/post(), urllib.urlopen(), or httpx called with URLs derived from user input?",
            # Dangerous functions
            "Are exec() or eval() called with any user-controlled string?",
            # SAST — crypto
            "Are hashlib.md5() or hashlib.sha1() used for security-sensitive operations without a secure alternative?",
        ]

    # ------------------------------------------------------------------ #
    # A09: Logging & Monitoring files                                      #
    # ------------------------------------------------------------------ #
    if is_logging or (is_python and "log" in norm) or (is_js_ts and "log" in norm):
        questions += [
            "Are passwords, API keys, tokens, or secrets logged at any log level?",
            "Are full request bodies (which may contain PII) logged without field filtering?",
            "Are PII fields (email, phone, SSN, card numbers) logged without masking or redaction?",
            "Are authentication successes, failures, and admin actions recorded in an audit log?",
            "Are critical data changes (create, update, delete on sensitive entities) logged with user ID and timestamp?",
            "Do log entries avoid including SQL queries or stack traces that reveal system internals?",
        ]

    # ------------------------------------------------------------------ #
    # A05 + A08: Application config files (non-infra YAML/TOML/ini)        #
    # ------------------------------------------------------------------ #
    # (already covered by is_config above — no duplicate block needed)

    # ------------------------------------------------------------------ #
    # Container & DevOps: Dockerfile                                       #
    # ------------------------------------------------------------------ #
    if is_dockerfile:
        questions += [
            "Does FROM use :latest or an unpinned/potentially EOL tag?",
            "Is there a USER instruction — does the container run as a non-root user?",
            "Are secrets, passwords, or API keys present in ENV or ARG instructions (baked into the image)?",
            "Does any RUN step pipe from the internet (curl | bash) or use ADD <url> without checksum verification?",
            "Are apt-get/apk packages installed without pinned versions (supply chain risk)?",
            "Is HEALTHCHECK defined so orchestrators can detect unhealthy containers?",
            "Are credentials stored in image layers via COPY of .env or secrets files?",
        ]

    # ------------------------------------------------------------------ #
    # Container & DevOps: docker-compose                                   #
    # ------------------------------------------------------------------ #
    if is_compose:
        questions += [
            "Are any services using privileged: true (full host privileges)?",
            "Are sensitive host paths mounted (/etc, /var/run/docker.sock, /proc, /)?",
            "Are hardcoded passwords or secrets in the environment: section (not from env_file or secrets:)?",
            "Are database or admin-panel ports mapped to 0.0.0.0 (accessible from all network interfaces)?",
            "Is network_mode: 'host' used (bypasses network namespace isolation)?",
        ]

    # ------------------------------------------------------------------ #
    # Container & DevOps: CI/CD pipelines                                  #
    # ------------------------------------------------------------------ #
    if is_cicd:
        questions += [
            "Are secrets echoed or printed in run: steps (echo $SECRET, env, printenv)?",
            "Are third-party actions pinned to a full commit SHA digest (not a mutable tag like @v3)?",
            "Does pull_request_target trigger checkout of the PR branch — enabling script injection?",
            "Are GITHUB_TOKEN permissions limited to the minimum needed (not write-all)?",
            "Are environment variables with secret-sounding names set to literal string values (not from secrets:)?",
            "Are artifact checksums or signatures verified before promotion to production?",
            "Are long-lived static cloud credentials used instead of OIDC federated identity?",
        ]

    # ------------------------------------------------------------------ #
    # Cloud: Kubernetes manifests                                          #
    # ------------------------------------------------------------------ #
    if is_k8s:
        questions += [
            "Are any pods or containers using securityContext.privileged: true?",
            "Are hostPath volumes, hostNetwork: true, or hostPID: true used?",
            "Are ClusterRole bindings granting wildcard verbs ('*') or resources ('*') to broad subjects?",
            "Is cluster-admin bound to any service account or group beyond kube-system?",
            "Are Kubernetes Dashboard services exposed via LoadBalancer or NodePort without authentication?",
            "Does any pod spec omit runAsNonRoot: true and a non-zero runAsUser?",
            "Are secrets mounted as environment variables (visible in process list) instead of volume mounts?",
        ]

    # ------------------------------------------------------------------ #
    # Cloud: Terraform / IAM policies                                     #
    # ------------------------------------------------------------------ #
    if is_terraform or is_iam:
        questions += [
            "Are any IAM policies granting wildcard actions ('*') or resources ('*')?",
            "Is the principal set to '*' in any resource-based policy (world-readable/writable)?",
            "Are S3 bucket policies or ACLs granting public read or write access?",
            "Are GCS buckets or Azure Blob containers set to public access level?",
            "Are security groups open to 0.0.0.0/0 on sensitive ports (22, 3306, 5432, 6379, 27017)?",
            "Is IMDSv1 enabled on EC2 instances (should require token-based IMDSv2)?",
            "Are database instances (RDS, CloudSQL, Azure SQL) publicly accessible?",
            "Do IAM policies lack condition constraints (IP allowlist, MFA required, time-of-day)?",
        ]

    # ------------------------------------------------------------------ #
    # A06: Dependency manifests                                            #
    # ------------------------------------------------------------------ #
    if is_manifest:
        questions += [
            "Are any dependencies pinned to a specific version (not a floating range like ^1.x or *)?",
            "Do you recognize any package names that appear to be typosquats of popular libraries?",
            "Are there packages with known CVEs at the listed version (check OSV, NVD, Snyk)?",
            "Are dev-only packages (test runners, linters) excluded from production builds?",
            "Is there a lockfile present and committed (prevents dependency confusion attacks)?",
        ]

    # ------------------------------------------------------------------ #
    # GIGW Code Security (Indian Government Website Guidelines)            #
    # ------------------------------------------------------------------ #
    if is_gigw or any(s in norm for s in ("gov.in", "nic.in", "india.gov", "gigw")):
        questions += [
            # GIGW Code Security
            "Are hardcoded passwords, tokens, or API keys present in source code?",
            "Do error handlers avoid returning source code, stack traces, or internal paths to clients?",
            "Are all session cookies set with Secure and HttpOnly flags?",
            "Is user input validated on both client and server side before processing?",
            "Are passwords stored using salted hashing (bcrypt/argon2) — never plain text or weak hash?",
            "Is a strong password policy enforced in code (length, complexity)?",
            "Is the password recovery mechanism secure (token-based, single-use, time-limited)?",
            "Is multi-factor authentication implemented for sensitive routes?",
            "Is role-based access control with least privilege enforced throughout?",
            "Are sessions configured to expire, invalidate on logout, and regenerate on login?",
            "Is HTTPS enforced everywhere and is the HSTS header set?",
            "Are weak TLS ciphers and protocols (SSLv3, TLS 1.0/1.1, RC4, DES) disabled?",
            "Is path traversal prevented and directory listing disabled?",
            "Do all third-party API communications use encrypted channels?",
            "Is comprehensive audit logging present with no sensitive data (passwords/tokens) in logs?",
            "Are database credentials loaded from environment variables, not hardcoded in source?",
            "Is CAPTCHA present on login and sensitive form pages?",
            "Is rate limiting applied to authentication and all sensitive endpoints?",
            # GIGW Quality (for HTML/template files)
            "Is the government emblem/logo displayed prominently on the homepage?",
            "Are all required policy pages (Privacy, Terms, Copyright, Accessibility) present and linked in the footer?",
            "Is bilingual support (English + Hindi) present with a prominent language selector?",
            "Are there no broken or placeholder links on any pages?",
            "Is a sitemap (HTML for users, XML for search engines) present?",
            "Is there an accessibility conformance statement linked on the homepage?",
            # GIGW Government Mandates (mandatory legal/policy pages)
            "Is an RTI (Right to Information) section present and linked from the main navigation?",
            "Is a public grievance redressal mechanism present and operational?",
            "Is a Citizen Charter published for service-delivering government bodies?",
            "Is a Disclaimer page present and linked from the footer?",
            "Is the government copyright notice displayed on all pages?",
            "Is a Hyperlinking Policy published and linked from the footer?",
            "Are Terms of Use published and linked separately from the Privacy Policy?",
            "Are annual reports and official publications accessible and linked?",
            # GIGW additional Code Security checks
            "Are HTTP security response headers set — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options?",
            "Does the CSP restrict unsafe-inline scripts and unsafe-eval?",
            "Is X-Frame-Options or CSP frame-ancestors set to prevent clickjacking?",
            "Is there evidence of VAPT (Vulnerability Assessment and Penetration Testing) having been conducted?",
            "Is a CERT-In security contact or cyber incident reporting mechanism configured?",
            "Has the website obtained CERT-In Safe-to-Host cybersecurity clearance?",
        ]

    # Deduplicate while preserving order
    seen: set[str] = set()
    unique: list[str] = []
    for q in questions:
        if q not in seen:
            seen.add(q)
            unique.append(q)
    return unique


def _analysis_instructions() -> str:
    return (
        "LLM-guided scan instructions:\n"
        "1) Call list_security_checks to see available checks/profiles.\n"
        "2) Call security_scan with include_content=true and offset=0 to fetch the first file batch.\n"
        "3) Analyze each file using the security analysis methodology below.\n"
        "4) Call security_scan again with findings=[...from previous batch] and the next offset to submit findings and fetch the next batch.\n"
        "5) Repeat until hasMore=false, then call with findings=[...last batch] and is_last=true to finalize.\n"
        "6) The final response includes the full scan report and all findings.\n"
        "7) After completion, call push_to_maeris with data_type='vulnerabilities' and scan_id to publish the results.\n"
        "Note: MCP does not perform detection; the LLM must analyze and produce findings.\n"
        "Note: File contents are prefixed with line numbers (e.g. '  1| code...'). "
        "Use these exact line numbers for lineStart/lineEnd in your findings.\n"
        "\n"
        "=== SECURITY ANALYSIS METHODOLOGY ===\n"
        "You are an expert security auditor. For each file, perform deep analysis using\n"
        "these principles. Do NOT rely only on pattern matching — reason about the code's\n"
        "security implications. Your goal is to find ALL vulnerabilities, including novel\n"
        "ones not covered by textbook examples.\n"
        "\n"
        "STEP 1: IDENTIFY TRUST BOUNDARIES\n"
        "- Where does external/untrusted input enter? (HTTP params, headers, body, cookies,\n"
        "  URL paths, file uploads, env vars, database reads, message queues, webhooks,\n"
        "  third-party API responses, WebSocket messages, CLI arguments)\n"
        "- Where does the code interact with sensitive systems? (databases, file system,\n"
        "  OS commands, network requests, auth systems, crypto operations,\n"
        "  serialization/deserialization, template engines, code evaluation)\n"
        "- What data crosses from untrusted to trusted contexts without validation?\n"
        "\n"
        "STEP 2: TRACE DATA FLOWS (TAINT ANALYSIS)\n"
        "For each source of untrusted input:\n"
        "- Follow the data through the code — is it validated, sanitized, or encoded\n"
        "  before reaching a sensitive sink?\n"
        "- Track direct usage, indirect usage through variables/assignments, and usage\n"
        "  after transformation or string interpolation.\n"
        "- Check if validation is adequate for the SPECIFIC context (e.g., HTML encoding\n"
        "  doesn't prevent SQL injection; URL validation doesn't prevent SSRF to internal IPs).\n"
        "- Consider ALL injection types: SQL, NoSQL, command, LDAP, XSS, SSTI, header,\n"
        "  XPath, code injection, deserialization — wherever untrusted data meets an interpreter.\n"
        "\n"
        "STEP 3: CHECK FOR MISSING SECURITY CONTROLS\n"
        "Vulnerabilities are often about what's ABSENT, not just what's present:\n"
        "- Missing authentication on routes that access or modify data\n"
        "- Missing authorization checks (ownership verification, role enforcement, tenant isolation)\n"
        "- Missing input validation before sensitive operations\n"
        "- Missing output encoding/escaping for the output context\n"
        "- Missing security headers in server/framework configuration\n"
        "- Missing rate limiting on authentication or sensitive endpoints\n"
        "- Missing encryption for sensitive data at rest or in transit\n"
        "- Sensitive fields (passwords, tokens, internal IDs) not excluded from API responses\n"
        "- Missing CSRF protection on state-changing endpoints\n"
        "- Missing session invalidation on logout or privilege change\n"
        "\n"
        "STEP 3b: CHECK FOR SENSITIVE DATA EXPOSURE IN UI COMPONENTS\n"
        "When analyzing frontend files (React, Vue, Angular, Svelte, JSX, TSX, templates):\n"
        "- Check if password, secret, token, key, or credential fields from user/API objects\n"
        "  are rendered directly (e.g., {user.password}, {profile.secret}, value={data.token})\n"
        "- Look for form inputs, table cells, text nodes, or console.log that emit sensitive fields\n"
        "- Check if API responses containing passwords or secrets are stored in state and then\n"
        "  rendered anywhere in the component tree, even in 'hidden' or admin-only views\n"
        "- Flag any component that receives a password field from the server and renders it —\n"
        "  the server should never return passwords at all; receiving one is itself a violation\n"
        "- Profile pages, account settings, and admin panels are high-risk locations\n"
        "\n"
        "STEP 4: EVALUATE CRYPTOGRAPHIC & SECRET MANAGEMENT\n"
        "- Hardcoded secrets, API keys, database credentials, or fallback/default values in source\n"
        "- Weak hashing algorithms used for security purposes (passwords, tokens, signatures)\n"
        "- JWT configuration: algorithm restrictions, secret strength, claim validation (exp, iss, aud)\n"
        "- Cookie security attributes: Secure, HttpOnly, SameSite\n"
        "- Key/certificate management: key length, rotation, storage\n"
        "- Protocol choices: TLS version requirements, cipher suite selection\n"
        "\n"
        "STEP 5: ASSESS CONFIGURATION SECURITY\n"
        "- Framework/server config files for security-relevant settings\n"
        "- CORS policies, CSP directives, security response headers\n"
        "- Debug/development flags that should be disabled in production\n"
        "- Information disclosure through headers, error responses, or verbose logging\n"
        "- Infrastructure-as-code: IAM policies, storage ACLs, network rules, container privileges\n"
        "\n"
        "STEP 5b: CONTAINER & CI/CD INFRASTRUCTURE ANALYSIS\n"
        "When the batch contains Dockerfile, docker-compose*.yml, or CI/CD YAML files\n"
        "(.github/workflows/*.yml, Jenkinsfile, .circleci/config.yml, .gitlab-ci.yml, etc.),\n"
        "apply the following checks from the 'Container & DevOps Security' catalog section:\n"
        "\n"
        "Dockerfile:\n"
        "- FROM <image>:latest or a known EOL/old tag → vulnerable base image (flag with version)\n"
        "- No USER instruction or USER root → container runs as root\n"
        "- ENV or ARG with names containing PASSWORD, SECRET, TOKEN, KEY, API, CREDENTIAL → baked-in secret\n"
        "- ADD <url> → arbitrary remote file download; prefer COPY\n"
        "- No HEALTHCHECK instruction → unmonitored container\n"
        "- RUN apt-get without pinned versions or --no-install-recommends → supply chain risk\n"
        "\n"
        "docker-compose.yml / docker-compose.*.yml:\n"
        "- privileged: true → container gets full host privileges\n"
        "- network_mode: 'host' → bypasses network namespace isolation\n"
        "- volumes mounting sensitive host paths: /etc, /var/run/docker.sock, /, /proc\n"
        "- Hardcoded passwords/secrets in environment: or env_file: inline values\n"
        "- Exposed ports mapped to 0.0.0.0 for sensitive services (DB, Redis, admin UIs)\n"
        "\n"
        "CI/CD YAML (.github/workflows, Jenkinsfile, .circleci, etc.):\n"
        "- Secrets echoed/printed in run: steps (echo $SECRET, printenv, env output)\n"
        "- pull_request_target trigger combined with actions/checkout of the PR branch → script injection\n"
        "- Third-party actions (uses: owner/action@branch or @v1) without pinned SHA digest\n"
        "- GITHUB_TOKEN or other tokens granted write permissions beyond what's needed\n"
        "- Environment variables with secret names set to literal string values (not from secrets:)\n"
        "- Missing OIDC token usage for cloud auth (using long-lived static credentials instead)\n"
        "\n"
        "STEP 6: EVALUATE DEPENDENCY RISK\n"
        "- Flag packages you know to have CVEs at the specified version\n"
        "- Flag packages with inherently dangerous capabilities (serialization, code execution,\n"
        "  template compilation) especially when used with untrusted input\n"
        "- Flag significantly outdated major versions that likely lack security patches\n"
        "\n"
        "STEP 7: CONSIDER FRAMEWORK & ARCHITECTURAL RISKS\n"
        "- Identify the framework from file structure and imports, then apply\n"
        "  framework-specific security knowledge\n"
        "- Check that framework-specific security features are properly used\n"
        "- Look for architectural issues: improper separation of client/server code,\n"
        "  trust placed in client-side controls, missing middleware in request pipeline\n"
        "- Consider business logic flaws: race conditions, workflow bypass, privilege\n"
        "  escalation through parameter manipulation\n"
        "\n"
        "=== ANALYSIS PRINCIPLES ===\n"
        "1. THINK LIKE AN ATTACKER: For each file, ask 'How could an attacker abuse this?'\n"
        "   Consider both the obvious and the subtle.\n"
        "2. CONTEXT MATTERS: A function is only vulnerable if untrusted data can reach it.\n"
        "   Trace the actual data flow — don't just flag API names in isolation.\n"
        "3. DEFENSE IN DEPTH: A missing security layer is a finding even if other layers exist.\n"
        "4. LOOK BEYOND KNOWN PATTERNS: Use your full security expertise to identify novel\n"
        "   risks, emerging vulnerability classes, and architectural weaknesses — not just\n"
        "   textbook examples. The check catalog is a starting point, not a ceiling.\n"
        "5. CLASSIFY CORRECTLY using owaspCategory:\n"
        "   - Security findings → OWASP A01-A10 + CWE ID\n"
        "   - GIGW findings → GIGW-SEC, GIGW-A11Y, GIGW-QUAL, GIGW-LIFE, or GIGW-GOV\n"
        "   - Accessibility findings → WCAG-A, WCAG-AA, or WCAG-AAA\n"
        "6. BE THOROUGH, NOT NOISY: Only report issues with genuine impact.\n"
        "   Explain WHY it's a problem and HOW to fix it.\n"
        "\n"
        "=== MANDATORY: GIGW & ACCESSIBILITY FINDINGS ===\n"
        "If the scan profile includes GIGW or accessibility checks, this section is CRITICAL.\n"
        "DO NOT treat this as a security-only scan. You MUST evaluate and report findings\n"
        "for ALL three categories: security, GIGW, AND accessibility.\n\n"
        "For EVERY batch of files, ask yourself:\n"
        "  - Security: What vulnerabilities exist? (owaspCategory: A01-A10)\n"
        "  - GIGW: Does this file violate any GIGW guidelines? (owaspCategory: GIGW-SEC/A11Y/QUAL/LIFE/GOV)\n"
        "  - Accessibility: Are there WCAG violations? (owaspCategory: WCAG-A/AA/AAA)\n\n"
        "GIGW checks to evaluate on EVERY frontend file (HTML, JSX, TSX, templates):\n"
        "  - Missing skip navigation link on pages\n"
        "  - Hindi/regional language toggle not available\n"
        "  - RTI (Right to Information) section missing\n"
        "  - Missing accessibility statement page\n"
        "  - Missing sitemap page\n"
        "  - Missing contact us / feedback page\n"
        "  - Missing disclaimer / terms / privacy pages\n"
        "  - Missing Content-Security-Policy, X-Frame-Options, HSTS headers\n"
        "  - No CERT-In security contact configured\n\n"
        "Accessibility checks to evaluate on EVERY frontend file:\n"
        "  - Images without alt text\n"
        "  - Form inputs without associated <label> elements\n"
        "  - Interactive elements not keyboard accessible (missing tabIndex, onClick without onKeyDown)\n"
        "  - Missing ARIA roles/labels on dynamic content\n"
        "  - Color contrast issues (hardcoded low-contrast colors)\n"
        "  - Missing lang attribute on <html>\n"
        "  - Missing page title / heading hierarchy issues\n"
        "  - Non-accessible custom components (dropdowns, modals, carousels without ARIA)\n\n"
        "A scan that returns 40 security findings and only 2-3 GIGW/accessibility findings\n"
        "is a FAILED scan. Frontend-heavy apps typically have MORE accessibility issues than\n"
        "security issues. If you're finding fewer, you're not looking hard enough.\n"
        "\n"
        "=== OUTPUT REQUIREMENTS ===\n"
        "- Analyze EVERY file thoroughly. Do not skip files or rush through batches.\n"
        "- For each finding, provide: exact file path, line number(s), code snippet,\n"
        "  severity, category (owaspCategory), and a specific remediation recommendation.\n"
        "- CRITICAL: filePath and lineStart are REQUIRED and must not be empty/zero.\n"
        "  Use the exact relative file path from the fileContents keys.\n"
        "  Use the exact line number shown in the line-numbered content (e.g. '42| code' means lineStart=42).\n"
        "- The recommendation field MUST contain actionable fix guidance. Provide at least one of:\n"
        "  (a) A corrected code snippet showing how to fix the vulnerable code.\n"
        "  (b) Step-by-step remediation instructions specific to this codebase.\n"
        "  (c) Links to official documentation, security advisories, or framework guides.\n"
        "  Be concrete — reference actual file paths, function names, and framework APIs.\n"
        "  Bad: 'Sanitize user input.' Good: 'Use parameterized queries with db.execute(sql, params)\n"
        "  instead of f-string interpolation at auth/login.py:42. See https://cheatsheetseries.owasp.org/...'\n"
        "- Finding titles MUST be verbose and include the full category name:\n"
        "  Security: 'OWASP A03:2021 (Injection): SQL Injection via Unsanitized User Input in Login Handler'\n"
        "  GIGW:     'GIGW-SEC (GIGW Code Security): Missing Content-Security-Policy Header'\n"
        "  GIGW:     'GIGW-A11Y (GIGW Accessibility): Hindi Language Toggle Not Available on Homepage'\n"
        "  GIGW:     'GIGW-GOV (GIGW Government Mandates): RTI Section Not Linked from Main Navigation'\n"
        "  WCAG:     'WCAG-AA (WCAG Level AA): Form Inputs Missing Associated Labels'\n"
        "  WCAG:     'WCAG-A (WCAG Level A): Images Missing Alt Text Attributes'\n"
        "- Check config/manifest files for security misconfigurations, not just code files.\n"
        "- When unsure about severity, consider the worst realistic exploitation scenario.\n"
        "\n"
        "=== COMPLIANCE SCORECARD: checkResults (REQUIRED on is_last=true) ===\n"
        "On the FINAL call (is_last=true), you MUST include a 'checkResults' array alongside findings.\n"
        "This is how the compliance scorecard (% passed) is calculated.\n\n"
        "For EVERY check ID in selectedChecks that starts with a GIGW or accessibility/WCAG section,\n"
        "report: {checkId: '<exact ID>', status: 'pass'|'fail'|'not_applicable', notes: '<brief reason>'}\n\n"
        "Rules:\n"
        "- 'pass' = you reviewed the codebase and this check's requirement IS met\n"
        "- 'fail' = the requirement is NOT met (there MUST be a corresponding finding in findings[])\n"
        "- 'not_applicable' = this check doesn't apply to this type of project (e.g., container checks for a frontend-only app)\n"
        "- You MUST report a result for EVERY GIGW and WCAG check — do not skip any\n"
        "- Security checks (A01-A10) do NOT need checkResults — those are scored by findings only\n"
        "- If you're unsure whether a check passes, mark it 'fail' and add a finding — err on the side of caution\n\n"
        "Example checkResults:\n"
        "[\n"
        '  {"checkId": "gigw_code_security_csp_header", "status": "fail", "notes": "No CSP header configured in next.config.mjs"},\n'
        '  {"checkId": "gigw_accessibility_hindi_toggle", "status": "fail", "notes": "No Hindi language option found"},\n'
        '  {"checkId": "gigw_quality_sitemap", "status": "fail", "notes": "No sitemap.xml or sitemap page"},\n'
        '  {"checkId": "gigw_lifecycle_backup", "status": "not_applicable", "notes": "Frontend-only app, no backup config"},\n'
        '  {"checkId": "wcag_21_aa_color_contrast", "status": "pass", "notes": "All text meets 4.5:1 contrast ratio"}\n'
        "]\n"
    )


OWASP_CANONICAL_SEVERITY: dict[str, str] = {
    "A01": "high",      # Broken Access Control
    "A02": "high",      # Cryptographic Failures
    "A03": "critical",  # Injection
    "A04": "medium",    # Insecure Design
    "A05": "medium",    # Security Misconfiguration
    "A06": "medium",    # Vulnerable and Outdated Components
    "A07": "high",      # Identification and Authentication Failures
    "A08": "high",      # Software and Data Integrity Failures
    "A09": "low",       # Security Logging and Monitoring Failures
    "A10": "high",      # SSRF
    # GIGW
    "GIGW-SEC": "high",
    "GIGW-A11Y": "medium",
    "GIGW-QUAL": "medium",
    "GIGW-LIFE": "low",
    "GIGW-GOV": "high",
    # Accessibility
    "WCAG-A": "high",
    "WCAG-AA": "medium",
    "WCAG-AAA": "low",
}

_SEVERITY_ORDER = ["low", "medium", "high", "critical"]


def _normalize_severity(value: str | None) -> Severity:
    if not value:
        return Severity.MEDIUM
    try:
        return Severity(value.lower())
    except Exception:
        return Severity.MEDIUM


def _normalize_confidence(value: str | None) -> Confidence:
    if not value:
        return Confidence.MEDIUM
    try:
        return Confidence(value.lower())
    except Exception:
        return Confidence.MEDIUM


def _normalize_owasp(value: str | None) -> OWASPCategory | None:
    if not value:
        return None
    cleaned = value.strip()
    if ":" in cleaned:
        cleaned = cleaned.split(":", 1)[0]
    cleaned = cleaned.upper()
    try:
        return OWASPCategory(cleaned)
    except Exception:
        return None


def _add_line_numbers(content: str) -> str:
    """Prefix each line with its 1-based line number so the LLM can reference exact lines."""
    if not content:
        return content
    lines = content.splitlines(True)
    width = len(str(len(lines)))
    return "".join(f"{i:{width}d}| {line}" for i, line in enumerate(lines, 1))


_INFRA_HINT_RE = re.compile(
    r"(^|/)Dockerfile(\..+)?$"
    r"|(^|/)docker-compose[^/]*\.ya?ml$"
    r"|(^|/)\.github/workflows/[^/]+\.ya?ml$"
    r"|(^|/)Jenkinsfile(\..+)?$"
    r"|(^|/)(\.circleci/config|\.travis|\.gitlab-ci|bitbucket-pipelines|azure-pipelines|cloudbuild)\.ya?ml$"
    r"|(^|/)(k8s|kubernetes|terraform|infra)/.*\.ya?ml$"
    r"|\.tf(vars)?$",
    re.I,
)


def _slugify(value: str) -> str:
    cleaned = "".join(ch.lower() if ch.isalnum() else "_" for ch in value)
    cleaned = "_".join([p for p in cleaned.split("_") if p])
    return cleaned or "finding"


def _normalize_finding(raw: dict[str, Any]) -> SecurityFinding:
    title = raw.get("title") or raw.get("name") or "Security finding"
    rule_id = raw.get("ruleId") or raw.get("rule_id") or _slugify(title)
    file_path = raw.get("filePath") or raw.get("file_path") or ""
    line_start = raw.get("lineStart") or raw.get("line_start") or 0
    line_end = raw.get("lineEnd") or raw.get("line_end")

    owasp_cat = _normalize_owasp(raw.get("owaspCategory") or raw.get("owasp_category"))
    severity = _normalize_severity(raw.get("severity"))
    if owasp_cat:
        canonical = OWASP_CANONICAL_SEVERITY.get(owasp_cat.value)
        if canonical and _SEVERITY_ORDER.index(severity.value) < _SEVERITY_ORDER.index(canonical):
            severity = Severity(canonical)

    return SecurityFinding(
        id=raw.get("id") or str(uuid4()),
        rule_id=rule_id,
        owasp_category=owasp_cat,
        cwe_id=raw.get("cweId") or raw.get("cwe_id"),
        severity=severity,
        confidence=_normalize_confidence(raw.get("confidence")),
        title=title,
        description=raw.get("description") or f"Detected potential security issue: {title}",
        file_path=file_path,
        line_start=int(line_start) if isinstance(line_start, (int, str)) and str(line_start).isdigit() else 0,
        line_end=int(line_end) if isinstance(line_end, (int, str)) and str(line_end).isdigit() else None,
        code_snippet=raw.get("codeSnippet") or raw.get("code_snippet"),
        recommendation=raw.get("recommendation"),
        reference_links=raw.get("referenceLinks") or raw.get("reference_links") or [],
    )


def _resolve_checks(
    checks: list[dict[str, Any]],
    profiles: dict[str, list[str]],
    requested_checks: list[str] | None,
    scan_profile: str | list[str],
) -> tuple[list[str], str | None]:
    catalog_ids = {c.get("id") for c in checks}
    if requested_checks:
        invalid = [c for c in requested_checks if c not in catalog_ids]
        if invalid:
            return [], f"Invalid checks: {invalid}. Use list_security_checks to see valid IDs."
        return requested_checks, None
    # Normalize scan_profile to a list
    profile_list = scan_profile if isinstance(scan_profile, list) else [scan_profile]
    invalid_profiles = [p for p in profile_list if p not in profiles]
    if invalid_profiles:
        return [], f"Invalid scan_profile(s): {invalid_profiles}. Available: {sorted(profiles.keys())}"
    # Combine checks from all profiles, preserving order and deduplicating
    seen: set[str] = set()
    combined: list[str] = []
    for p in profile_list:
        for check_id in profiles[p]:
            if check_id not in seen:
                seen.add(check_id)
                combined.append(check_id)
    return combined, None


def _build_scan_report(scan: StoredSecurityScan) -> str:
    """Build a human-readable summary block for a finalized scan."""
    summary = scan.result.summary
    sev = summary.by_severity
    project_name = scan.target_path.split("/")[-1] or scan.target_path

    # Determine report title based on scan profile
    scan_profile = scan.scan_profile
    profile_list = scan_profile if isinstance(scan_profile, list) else [scan_profile or "owasp_top10_static"]
    profile_set = set(profile_list)

    has_security = any(is_security_profile(p) for p in profile_set)
    has_gigw = any(is_gigw_profile(p) for p in profile_set)
    has_accessibility = any(is_a11y_profile(p) for p in profile_set)

    title_parts = []
    if has_security:
        title_parts.append("Security")
    if has_gigw:
        title_parts.append("GIGW Compliance")
    if has_accessibility:
        title_parts.append("Accessibility")
    title = " + ".join(title_parts) if title_parts else "Security"

    lines = [
        f"{'=' * 60}",
        f"  {title} Scan Complete — {project_name}",
        f"{'=' * 60}",
        f"",
        f"Target: {scan.target_path}",
        f"Profiles: {', '.join(profile_list)}",
        f"Files scanned: {summary.files_scanned}",
        f"Total findings: {summary.total_findings} "
        f"({sev.critical} critical, {sev.high} high, {sev.medium} medium, {sev.low} low)",
    ]

    # Categorize findings by their owaspCategory code
    owasp_findings: dict[str, int] = {}
    gigw_findings: dict[str, int] = {}
    wcag_findings: dict[str, int] = {}

    if summary.by_owasp_category:
        for code, count in summary.by_owasp_category.items():
            if code.startswith("A") and len(code) <= 3:
                owasp_findings[code] = count
            elif code.startswith("GIGW"):
                gigw_findings[code] = count
            elif code.startswith("WCAG"):
                wcag_findings[code] = count

    # --- Security breakdown ---
    if has_security and owasp_findings:
        lines.append("")
        lines.append("─── Security (OWASP Top 10) ───")
        for code, count in sorted(owasp_findings.items()):
            display_name = OWASP_DISPLAY_NAMES.get(code, code)
            lines.append(f"  OWASP {code}:2021 ({display_name}): {count} finding(s)")

    # --- Build check results index ---
    # Index check results by check_id for fast lookup
    cr_by_id: dict[str, str] = {}  # check_id -> status
    cr_notes_by_id: dict[str, str] = {}  # check_id -> notes
    for cr in scan.check_results:
        cr_by_id[cr.check_id] = cr.status
        if cr.notes:
            cr_notes_by_id[cr.check_id] = cr.notes
    has_check_results = len(cr_by_id) > 0

    # --- GIGW Compliance Scorecard ---
    if has_gigw:
        # Reverse mapping: pillar code → display name ("GIGW-SEC" → "GIGW Code Security")
        gigw_section_names = {v: k for k, v in GIGW_SECTION_TO_PILLAR.items()}

        # Classify each GIGW check into its pillar using check metadata
        check_section_index = build_check_section_index(load_checks())
        gigw_checks_by_section: dict[str, list[str]] = {}
        for cid in scan.selected_checks:
            if "gigw" not in cid.lower():
                continue
            pillar = classify_gigw_check(cid, check_section_index)
            gigw_checks_by_section.setdefault(pillar, []).append(cid)

        # Score using actual check results if available, else fall back to findings count
        lines.append("")
        lines.append("─── GIGW Compliance Scorecard ───")

        if has_check_results:
            # Real scoring: count actual pass/fail/na per section
            total_evaluated = 0
            total_passed = 0
            total_failed = 0
            total_na = 0
            section_stats: dict[str, dict[str, int]] = {}

            for section, check_ids in gigw_checks_by_section.items():
                stats = {"total": 0, "pass": 0, "fail": 0, "na": 0, "not_reported": 0}
                for cid in check_ids:
                    status = cr_by_id.get(cid)
                    if status == "pass":
                        stats["pass"] += 1
                        stats["total"] += 1
                    elif status == "fail":
                        stats["fail"] += 1
                        stats["total"] += 1
                    elif status == "not_applicable":
                        stats["na"] += 1
                    else:
                        stats["not_reported"] += 1
                        stats["total"] += 1  # Not reported = assume evaluated but unknown
                section_stats[section] = stats
                total_evaluated += stats["total"]
                total_passed += stats["pass"]
                total_failed += stats["fail"]
                total_na += stats["na"]

            overall_scored = total_evaluated or 1
            compliance_pct = round((total_passed / overall_scored) * 100)

            lines.append(f"  Overall: {total_passed}/{overall_scored} checks passed ({compliance_pct}% compliant)")
            lines.append(f"  Failed: {total_failed} | Not Applicable: {total_na}")
            lines.append("")

            for section in ["GIGW-SEC", "GIGW-A11Y", "GIGW-QUAL", "GIGW-LIFE", "GIGW-GOV"]:
                stats = section_stats.get(section)
                if not stats or stats["total"] == 0:
                    continue
                display = gigw_section_names.get(section, section)
                s_pct = round((stats["pass"] / stats["total"]) * 100) if stats["total"] else 0
                icon = "✓" if stats["fail"] == 0 else "✗"
                lines.append(f"  {icon} {display}: {stats['pass']}/{stats['total']} passed ({s_pct}%)")
                if stats["fail"]:
                    lines.append(f"    → {stats['fail']} failed")
                if stats["na"]:
                    lines.append(f"    → {stats['na']} not applicable")
        else:
            # Fallback: old method (findings count)
            total_gigw_checks = sum(len(ids) for ids in gigw_checks_by_section.values()) or 1
            total_gigw_findings = sum(gigw_findings.values())
            checks_passed = max(0, total_gigw_checks - total_gigw_findings)
            compliance_pct = round((checks_passed / total_gigw_checks) * 100)
            lines.append(f"  Overall: {checks_passed}/{total_gigw_checks} checks passed ({compliance_pct}% compliant)")
            lines.append(f"  (Note: scoring based on finding counts — check-level results not available)")

    # --- Accessibility Compliance Scorecard ---
    if has_accessibility:
        # Collect WCAG check IDs from selected checks
        wcag_check_ids: list[str] = []
        for cid in scan.selected_checks:
            cid_lower = cid.lower()
            if "gigw" in cid_lower:
                continue
            if any(s in cid_lower for s in ("wcag", "a11y", "accessibility", "aria", "alt_text", "keyboard", "screen_reader", "color_contrast")):
                wcag_check_ids.append(cid)

        lines.append("")
        lines.append("─── Accessibility (WCAG) Scorecard ───")

        wcag_display = {
            "WCAG-A": "Level A (Must Fix)",
            "WCAG-AA": "Level AA (Should Fix)",
            "WCAG-AAA": "Level AAA (Nice to Have)",
        }

        if not wcag_check_ids:
            lines.append("  (No WCAG checks were selected for this scan)")
        elif has_check_results:
            # Real scoring from check results
            w_pass = sum(1 for cid in wcag_check_ids if cr_by_id.get(cid) == "pass")
            w_fail = sum(1 for cid in wcag_check_ids if cr_by_id.get(cid) == "fail")
            w_na = sum(1 for cid in wcag_check_ids if cr_by_id.get(cid) == "not_applicable")
            w_evaluated = w_pass + w_fail
            w_pct = round((w_pass / w_evaluated) * 100) if w_evaluated else 0

            lines.append(f"  Overall: {w_pass}/{w_evaluated} checks passed ({w_pct}% compliant)")
            lines.append(f"  Failed: {w_fail} | Not Applicable: {w_na}")

            if wcag_findings:
                lines.append("")
                for code in ["WCAG-A", "WCAG-AA", "WCAG-AAA"]:
                    if code in wcag_findings:
                        display = wcag_display.get(code, code)
                        lines.append(f"  ✗ {display}: {wcag_findings[code]} finding(s)")
        else:
            # Fallback
            wcag_total = len(wcag_check_ids) or 1
            total_wcag_findings = sum(wcag_findings.values())
            wcag_passed = max(0, wcag_total - total_wcag_findings)
            wcag_pct = round((wcag_passed / wcag_total) * 100)
            lines.append(f"  Overall: {wcag_passed}/{wcag_total} checks passed ({wcag_pct}% compliant)")
            if wcag_findings:
                lines.append("")
                for code in ["WCAG-A", "WCAG-AA", "WCAG-AAA"]:
                    if code in wcag_findings:
                        display = wcag_display.get(code, code)
                        lines.append(f"  ✗ {display}: {wcag_findings[code]} finding(s)")
            lines.append(f"  (Note: scoring based on finding counts — check-level results not available)")

    lines.append("")
    lines.append(f"{'=' * 60}")
    return "\n".join(lines)


async def handle_list_security_checks(arguments: dict) -> list[TextContent]:
    """Handle the list_security_checks tool call."""
    checks, profiles = get_checks_and_profiles()
    if not checks:
        return [TextContent(type="text", text="Error: No security checks available. Ensure security_checks.json is present.")]
    response = {
        "profiles": profiles,
        "checks": checks,
        "expectedOutputSchema": _expected_output_schema(),
        "analysisInstructions": _analysis_instructions(),
    }
    return [TextContent(type="text", text=json.dumps(response, indent=2))]


async def handle_security_scan(
    security_store: SecurityScanStore,
    arguments: dict,
) -> list[TextContent]:
    """Handle the security_scan tool call — wraps inner handler with error recovery."""
    scan_id = arguments.get("scan_id") or arguments.get("scanId")
    try:
        return await _handle_security_scan_inner(security_store, arguments)
    except Exception as exc:
        return [TextContent(type="text", text=json.dumps({
            "error": "internal_error",
            "message": str(exc),
            "scanId": scan_id,
            "hint": (
                "An unexpected server error occurred during the security scan. "
                "If scanId is shown above, the scan session is still active — "
                "retry this call with the same scanId, include_content, and offset to continue."
            ),
        }, indent=2))]


async def _handle_security_scan_inner(
    security_store: SecurityScanStore,
    arguments: dict,
) -> list[TextContent]:
    """Inner implementation of handle_security_scan."""
    target_path = arguments.get("target_path") or os.getcwd()
    scan_id = arguments.get("scan_id") or arguments.get("scanId")
    include_content = arguments.get("include_content", False)
    offset = arguments.get("offset", 0)
    limit = min(arguments.get("limit", 5), 10)

    checks, profiles = get_checks_and_profiles()
    check_lookup = {c["id"]: c for c in checks}
    status = "pending"

    _SCAN_STALE_HOURS = 24  # mark pending scans as failed after this many hours

    if scan_id:
        stored = security_store.get(scan_id)
        if not stored:
            return [TextContent(type="text", text=f"Error: scan not found: {scan_id}")]
        meta = security_store.get_meta(scan_id)
        if not meta:
            return [TextContent(type="text", text=f"Error: scan metadata missing: {scan_id}")]

        # Detect stalled scans: if a scan has been pending longer than the stale
        # threshold the LLM likely stopped paginating (e.g. context window exhausted).
        # Mark it failed so the user gets a clear error instead of silence.
        if stored.status == "pending":
            age_s = security_store.get_scan_age_seconds(scan_id)
            if age_s is not None and age_s > _SCAN_STALE_HOURS * 3600:
                security_store.fail_scan(scan_id)
                return [TextContent(type="text", text=json.dumps({
                    "error": "scan_stale",
                    "scanId": scan_id,
                    "message": (
                        f"Scan has been pending for >{_SCAN_STALE_HOURS}h without completing. "
                        "This usually means the LLM stopped paginating before all files were analyzed. "
                        "Please start a new scan."
                    ),
                }, indent=2))]

        file_list = stored.file_list
        base_for_rel = meta.base_for_rel
        display_target = stored.target_path
        selected_checks = stored.selected_checks
        scan_profile = stored.scan_profile or "all_static"
        scan_timestamp = stored.result.scan_timestamp
        info = stored.result.errors
        status = stored.status
    else:
        scan_root = os.path.abspath(target_path)
        if not os.path.exists(scan_root):
            return [TextContent(type="text", text=f"Error: path does not exist: {target_path}")]

        scan_profile = arguments.get("scan_profile") or arguments.get("scanProfile") or "owasp_top10_static"
        # Normalize: if it's a single-element list, keep as list for _resolve_checks
        if isinstance(scan_profile, str):
            pass  # single profile string, handled by _resolve_checks
        elif isinstance(scan_profile, list):
            if not scan_profile:
                scan_profile = "owasp_top10_static"
        requested_checks = arguments.get("checks") or []
        if isinstance(requested_checks, str):
            requested_checks = [requested_checks]
        selected_checks, error = _resolve_checks(checks, profiles, requested_checks, scan_profile)
        if error:
            return [TextContent(type="text", text=f"Error: {error}\nHint: call list_security_checks to view available checks.")]
        if not selected_checks:
            return [TextContent(type="text", text="Error: No checks selected. Use list_security_checks to choose checks or a profile.")]

        base_for_rel, display_target = _get_project_paths(scan_root)
        files_abs, info = collect_scan_files(scan_root)
        # Always use forward slashes so paths are consistent across platforms
        # (os.path.relpath returns backslashes on Windows, but the LLM always writes
        # forward slashes in JSON — a mismatch breaks coverage tracking).
        file_list = [Path(os.path.relpath(p, base_for_rel)).as_posix() for p in files_abs]

        scan_id = str(uuid4())
        timestamp = datetime.now(timezone.utc).isoformat()
        summary = ScanSummary(
            total_findings=0,
            by_severity=SeverityCounts(),
            by_owasp_category={},
            files_scanned=len(file_list),
        )
        scan_result = SecurityScanResult(
            scan_id=scan_id,
            target_path=display_target,
            scan_timestamp=timestamp,
            summary=summary,
            findings=[],
            errors=info,
        )
        security_store.create_scan(
            result=scan_result,
            file_list=file_list,
            selected_checks=selected_checks,
            scan_profile=scan_profile,
            scan_root=scan_root,
            base_for_rel=str(base_for_rel),
        )
        scan_timestamp = timestamp

        # Pre-populate with validated findings from the previous scan of this repo.
        # This anchors the LLM's stochastic output — unfixed findings always survive
        # across runs regardless of whether the LLM happens to re-report them.
        prior_findings = load_persistent_findings(scan_root, file_list, str(base_for_rel))
        if prior_findings:
            security_store.add_findings(scan_id, prior_findings)

        # Change-aware scanning: load file hashes from the previous scan so that
        # unchanged files can be identified during pagination and shown with a
        # "skip" hint, focusing LLM analysis effort on files that actually changed.
        prev_hashes = load_file_hashes(scan_root)
        if prev_hashes:
            scan_meta = security_store.get_meta(scan_id)
            if scan_meta:
                scan_meta.prev_file_hashes = prev_hashes

    # Finalize if is_last
    is_last = bool(arguments.get("is_last") or arguments.get("isLast"))

    # Acknowledge the previous batch BEFORE processing its findings.
    # "findings" key present means the LLM is submitting for the last served batch.
    if "findings" in arguments or is_last:
        security_store.acknowledge_last_batch(scan_id)

    # Process any submitted findings before building the response
    raw_findings = arguments.get("findings") or []
    if isinstance(raw_findings, dict):
        raw_findings = [raw_findings]
    findings_stats: dict[str, int] = {}
    # Separate check results embedded in findings (objects with checkId+status but no title)
    actual_findings = []
    embedded_check_results = []
    if raw_findings and isinstance(raw_findings, list):
        for item in raw_findings:
            if isinstance(item, dict) and ("checkId" in item or "check_id" in item) and "status" in item and item.get("status") in ("pass", "fail", "not_applicable") and not item.get("title"):
                embedded_check_results.append(item)
            elif isinstance(item, dict):
                actual_findings.append(item)
        if actual_findings:
            normalized = [_normalize_finding(r) for r in actual_findings]
            findings_stats = security_store.add_findings(scan_id, normalized)

    # Process check results (pass/fail/not_applicable per check)
    raw_check_results = arguments.get("checkResults") or arguments.get("check_results") or embedded_check_results or []
    if isinstance(raw_check_results, list) and raw_check_results:
        parsed_results: list[CheckResult] = []
        for raw_cr in raw_check_results:
            if not isinstance(raw_cr, dict):
                continue
            check_id = raw_cr.get("checkId") or raw_cr.get("check_id") or ""
            status = raw_cr.get("status") or "pass"
            if status not in ("pass", "fail", "not_applicable"):
                status = "pass"
            notes = raw_cr.get("notes")
            if check_id:
                parsed_results.append(CheckResult(check_id=check_id, status=status, notes=notes))
        if parsed_results:
            security_store.set_check_results(scan_id, parsed_results)

    total_files = len(file_list)
    response: dict[str, Any] = {
        "scanId": scan_id,
        "targetPath": display_target,
        "scanTimestamp": scan_timestamp,
        "status": status,
        "scanProfile": scan_profile,
        "filesTotal": total_files,
        "scanInfo": info,
        "availableProfiles": sorted(profiles.keys()),
        "catalogHint": "Call list_security_checks for full catalog.",
    }

    if findings_stats:
        response["findingsSubmitted"] = findings_stats

    # Live file coverage — tells the LLM how many files remain and lets the user verify
    # completeness without re-running the scan.
    coverage = security_store.get_file_coverage(scan_id, file_list)
    age_s = security_store.get_scan_age_seconds(scan_id) or 0
    response["scanProgress"] = {
        "filesTotal": coverage.get("totalFiles", total_files),
        "filesServed": coverage.get("filesServed", 0),
        "filesAcknowledged": coverage.get("filesAcknowledged", 0),
        "filesRemaining": coverage.get("totalFiles", total_files) - coverage.get("filesAcknowledged", 0),
        "scanAgeMinutes": int(age_s / 60),
    }

    # Only send heavy one-time context on the very first call (new scan creation).
    # On continuation calls the LLM already has these in its context window — repeating
    # them 80+ times for a 416-file scan would inject >2.5 MB of boilerplate and saturate
    # the context, causing missed findings and missing code snippets.
    is_new_scan = not bool(arguments.get("scan_id") or arguments.get("scanId"))
    if is_new_scan:
        response["analysisInstructions"] = _analysis_instructions()
        response["expectedOutputSchema"] = _expected_output_schema()
        response["selectedChecks"] = selected_checks
    else:
        # Compact grouped check list on every continuation — check IDs grouped by category.
        # ~5-8 KB vs ~35-45 KB for full objects with titles; keeps the LLM anchored to the
        # same check set without saturating the context window on large repos.
        checks_by_category: dict[str, list[str]] = {}
        for cid in selected_checks:
            if c := check_lookup.get(cid):
                cat = c.get("owasp_category") or c.get("section") or "other"
                checks_by_category.setdefault(cat, []).append(cid)
        response["selectedChecksGrouped"] = checks_by_category
        response["reminder"] = (
            "Continue analysis using the checks in selectedChecksGrouped (check IDs grouped by category). "
            "Submit findings=[...] for the previous batch alongside the next offset. "
            "Include codeSnippet, lineStart, lineEnd in every finding. "
            "MANDATORY: For EVERY frontend file (JSX, TSX, HTML, templates), you MUST check for:\n"
            "  1. Security issues (owaspCategory: A01-A10)\n"
            "  2. GIGW violations (owaspCategory: GIGW-SEC, GIGW-A11Y, GIGW-QUAL, GIGW-LIFE, GIGW-GOV) — "
            "check for missing skip-nav, missing Hindi toggle, missing accessibility statement, "
            "missing CSP headers, missing RTI section, missing sitemap, etc.\n"
            "  3. Accessibility/WCAG violations (owaspCategory: WCAG-A, WCAG-AA, WCAG-AAA) — "
            "check for missing alt text, missing form labels, non-keyboard-accessible elements, "
            "missing ARIA roles, color contrast issues, missing lang attr, heading hierarchy, etc.\n"
            "Do NOT submit a batch with only security findings if the files contain frontend code. "
            "Set is_last=true on the final call.\n"
            "IMPORTANT: On the FINAL call (is_last=true), you MUST include checkResults[] — "
            "the pass/fail verdict for EVERY GIGW and WCAG check in selectedChecks. "
            "This is how the compliance scorecard is calculated. Without checkResults, "
            "the scorecard will show 'No check results submitted' which looks bad in the report."
        )

    if is_last:
        # Guard: refuse to finalize if any files were never sent or never acknowledged.
        never_sent = coverage.get("neverSentToLLM", [])
        not_analyzed = coverage.get("receivedButNotAnalyzed", [])
        if never_sent or not_analyzed:
            response["status"] = "incomplete"
            response["coverageError"] = {
                "message": (
                    "Cannot finalize: not all files have been analyzed. "
                    "Fetch the remaining files and submit their findings before calling is_last=true."
                ),
                "neverSentToLLM": never_sent,
                "receivedButNotAnalyzed": not_analyzed,
                "hint": (
                    f"Files still pending: {len(never_sent) + len(not_analyzed)}. "
                    "Continue fetching batches (include_content=true) until scanProgress.filesRemaining=0."
                ),
            }
            return [TextContent(type="text", text=json.dumps(response, indent=2))]

        # Guard: require checkResults for GIGW/WCAG scans
        scan_for_check = security_store.get(scan_id)
        if scan_for_check and not scan_for_check.check_results:
            sp = scan_for_check.scan_profile
            sp_list = sp if isinstance(sp, list) else [sp or ""]
            has_compliance = any(is_compliance_profile(p) for p in sp_list)
            if has_compliance:
                # Get the GIGW/WCAG check IDs the LLM needs to report on
                _csi = build_check_section_index(load_checks())
                _compliance_sections = _GIGW_SECTIONS | _A11Y_SECTIONS
                gigw_wcag_check_ids = [
                    cid for cid in scan_for_check.selected_checks
                    if _csi.get(cid, "") in _compliance_sections
                ]
                response["status"] = "incomplete"
                response["checkResultsRequired"] = {
                    "message": (
                        "Cannot finalize: checkResults[] is REQUIRED for GIGW and accessibility scans. "
                        "Call security_scan again with is_last=true AND checkResults=[...] containing "
                        "the pass/fail/not_applicable verdict for every GIGW and WCAG check."
                    ),
                    "expectedCheckIds": gigw_wcag_check_ids[:20],  # Show first 20 as examples
                    "totalChecksNeeded": len(gigw_wcag_check_ids),
                    "format": {
                        "checkResults": [
                            {"checkId": "<exact check ID from selectedChecks>", "status": "pass|fail|not_applicable", "notes": "brief reason"},
                        ],
                    },
                }
                return [TextContent(type="text", text=json.dumps(response, indent=2))]

        finalized = security_store.finalize_scan(scan_id)
        if finalized:
            response["status"] = "completed"
            response["summary"] = finalized.result.summary.model_dump(by_alias=True)
            response["findings"] = [f.model_dump(by_alias=True) for f in finalized.result.findings]
            if finalized.check_results:
                response["checkResults"] = [cr.model_dump(by_alias=True) for cr in finalized.check_results]
            response["scanReport"] = _build_scan_report(finalized)
            response["presentationHint"] = (
                "IMPORTANT: Present the 'scanReport' text to the user AS-IS (it contains the "
                "compliance scorecards with pass/fail percentages). Do NOT rewrite or summarize "
                "the scorecards — show them verbatim. You may add commentary after the report."
            )
            # Persist findings + file hashes so future scans can:
            # 1. Pre-populate unfixed findings (prevents stochastic loss across runs).
            # 2. Detect unchanged files and skip redundant LLM analysis.
            scan_meta = security_store.get_meta(scan_id)
            if scan_meta:
                save_scan_cache(
                    scan_meta.scan_root,
                    finalized.result.findings,
                    scan_meta.current_file_hashes,
                )
        return [TextContent(type="text", text=json.dumps(response, indent=2))]

    if include_content:
        paginated_paths = file_list[offset:offset + limit]
        abs_paginated_paths = [str(Path(base_for_rel) / p) for p in paginated_paths]
        contents = get_file_contents(abs_paginated_paths)

        response["pagination"] = {
            "offset": offset,
            "limit": limit,
            "returned": len(paginated_paths),
            "total": total_files,
            "hasMore": offset + limit < total_files,
            "nextOffset": offset + limit if offset + limit < total_files else None,
        }

        # Change-aware scanning: compute md5 of each file's content.
        # Unchanged files (hash matches previous scan) are flagged with a skip hint
        # so the LLM focuses analysis effort on files that actually changed.
        # All hashes are accumulated in ScanMeta and persisted at finalize time.
        batch_meta = security_store.get_meta(scan_id)
        unchanged_in_batch: set[str] = set()
        for rel, abs_path in zip(paginated_paths, abs_paginated_paths):
            raw_content = contents.get(abs_path, "")
            h = hashlib.md5(raw_content.encode(errors="replace")).hexdigest()
            if batch_meta is not None:
                batch_meta.current_file_hashes[rel] = h
                if batch_meta.prev_file_hashes.get(rel) == h:
                    unchanged_in_batch.add(rel)

        _UNCHANGED_HEADER = (
            "# [UNCHANGED] This file has not changed since the last scan.\n"
            "# Its findings are already loaded from cache. You may submit findings=[]\n"
            "# for this file unless you notice something the previous scan clearly missed.\n"
            "#\n"
        )
        response["fileContents"] = {
            rel: (
                _UNCHANGED_HEADER + _add_line_numbers(contents.get(abs_path, ""))
                if rel in unchanged_in_batch
                else _add_line_numbers(contents.get(abs_path, ""))
            )
            for rel, abs_path in zip(paginated_paths, abs_paginated_paths)
        }

        # Per-file security checklist: give the LLM specific yes/no questions for
        # each changed file based on its role (route, auth, model, config, frontend, etc.).
        # Only included for changed/new files — unchanged files already have cached findings.
        file_checklist: dict[str, list[str]] = {}
        for rel in paginated_paths:
            if rel not in unchanged_in_batch:
                checklist = _file_type_security_checklist(rel)
                if checklist:
                    file_checklist[rel] = checklist
        if file_checklist:
            response["fileSecurityChecklist"] = file_checklist
            response["checklistHint"] = (
                "fileSecurityChecklist contains targeted yes/no questions for each changed file. "
                "Answer each question as you analyze the file. If the answer is 'no' or 'missing', "
                "report a finding. These questions are in addition to your general security analysis."
            )

        # If any infrastructure files are in this batch, signal the LLM to apply
        # Container & DevOps Security checks (those checks have empty owasp_category and
        # would otherwise be invisible without this explicit prompt).
        infra_files = [
            p for p in paginated_paths
            if _INFRA_HINT_RE.search(p.replace("\\", "/"))
        ]
        if infra_files:
            response["infraFilesInBatch"] = infra_files
            response["infraAnalysisHint"] = (
                "This batch contains infrastructure/container files. "
                "Apply checks from the 'Container & DevOps Security' section in selectedChecksGrouped. "
                "Follow STEP 5b in analysisInstructions for Dockerfile, docker-compose, and CI/CD YAML patterns."
            )

        # Track that these files were served to the LLM
        security_store.record_files_served(scan_id, paginated_paths)
        # Update scanProgress now that we've served a new batch
        coverage = security_store.get_file_coverage(scan_id, file_list)
        response["scanProgress"] = {
            "filesTotal": coverage.get("totalFiles", total_files),
            "filesServed": coverage.get("filesServed", 0),
            "filesAcknowledged": coverage.get("filesAcknowledged", 0),
            "filesRemaining": coverage.get("totalFiles", total_files) - coverage.get("filesAcknowledged", 0),
        }

        # Run automated dependency scanning on manifest files in this batch
        if offset == 0:
            # Collect all manifest files from the full file list for dep scanning
            manifest_names = {"package.json", "requirements.txt", "requirements-dev.txt"}
            manifest_paths = [p for p in file_list if Path(p).name.lower() in manifest_names]
            if manifest_paths:
                abs_manifest_paths = [str(Path(base_for_rel) / p) for p in manifest_paths]
                manifest_contents = get_file_contents(abs_manifest_paths)
                rel_manifest_contents = {
                    rel: manifest_contents.get(abs_path, "")
                    for rel, abs_path in zip(manifest_paths, abs_manifest_paths)
                }
                dep_findings, dep_error = scan_dependencies(rel_manifest_contents)
                if dep_error:
                    response["depScanError"] = dep_error
                if dep_findings:
                    normalized = [_normalize_finding(f) for f in dep_findings]
                    stats = security_store.add_findings(scan_id, normalized)
                    response["depScanResults"] = {
                        "found": len(dep_findings),
                        "added": stats.get("added", 0),
                        "note": f"Automated dependency scan found {len(dep_findings)} vulnerable package(s). These findings have been auto-submitted.",
                    }
                # Mark manifest files as processed regardless of whether dep findings were found.
                # They are auto-scanned by the server and never go through the LLM batch flow,
                # so they must be excluded from the coverage guard's neverSentToLLM list.
                security_store.mark_files_auto_processed(scan_id, manifest_paths)

            # Run automated Dockerfile / infra-as-code scanning
            _INFRA_SCAN_NAMES = re.compile(
                r"^(Dockerfile(\..+)?|docker-compose[^/]*\.ya?ml"
                r"|\.circleci[\\/]config\.ya?ml|Jenkinsfile(\..+)?)$",
                re.IGNORECASE,
            )
            infra_scan_paths = [
                p for p in file_list
                if _INFRA_SCAN_NAMES.match(Path(p).name)
                or _INFRA_SCAN_NAMES.match(p.replace("\\", "/").split("/")[-1])
                or re.search(r"\.github/workflows/[^/]+\.ya?ml$", p.replace("\\", "/"), re.I)
            ]
            if infra_scan_paths:
                abs_infra = [str(Path(base_for_rel) / p) for p in infra_scan_paths]
                infra_contents = get_file_contents(abs_infra)
                rel_infra_contents = {
                    rel: infra_contents.get(abs_p, "")
                    for rel, abs_p in zip(infra_scan_paths, abs_infra)
                }
                infra_findings, infra_error = scan_dockerfile_secrets(rel_infra_contents)
                if infra_error:
                    response["infraScanError"] = infra_error
                if infra_findings:
                    normalized = [_normalize_finding(f) for f in infra_findings]
                    stats = security_store.add_findings(scan_id, normalized)
                    response["infraScanResults"] = {
                        "found": len(infra_findings),
                        "added": stats.get("added", 0),
                        "note": (
                            f"Automated infra scan found {len(infra_findings)} issue(s). "
                            "Auto-submitted. LLM will also analyze these files for context-dependent issues."
                        ),
                    }
                # Do NOT mark infra files as auto-processed — the LLM must still analyze
                # them for logic-level issues (privilege escalation chains, multi-stage builds,
                # COPY vs ADD reasoning) that regex cannot detect.

            # Run automated JSX/frontend sensitive data exposure scanning
            _JSX_EXTS = {".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"}
            _TEST_PATH_RE_INLINE = re.compile(
                r"[\\/](?:__tests__|tests?|spec|e2e)[\\/]|\.(?:test|spec)\.\w+$",
                re.IGNORECASE,
            )
            jsx_scan_paths = [
                p for p in file_list
                if Path(p).suffix.lower() in _JSX_EXTS
                and not _TEST_PATH_RE_INLINE.search(p.replace("\\", "/"))
            ]
            if jsx_scan_paths:
                abs_jsx = [str(Path(base_for_rel) / p) for p in jsx_scan_paths]
                jsx_file_contents = get_file_contents(abs_jsx)
                rel_jsx_contents = {
                    rel: jsx_file_contents.get(abs_p, "")
                    for rel, abs_p in zip(jsx_scan_paths, abs_jsx)
                }
                jsx_findings, jsx_error = scan_sensitive_ui_exposure(rel_jsx_contents)
                if jsx_error:
                    response["jsxScanError"] = jsx_error
                if jsx_findings:
                    normalized = [_normalize_finding(f) for f in jsx_findings]
                    stats = security_store.add_findings(scan_id, normalized)
                    response["jsxScanResults"] = {
                        "found": len(jsx_findings),
                        "added": stats.get("added", 0),
                        "note": (
                            f"Automated frontend scan found {len(jsx_findings)} sensitive data "
                            "exposure issue(s). Auto-submitted."
                        ),
                    }
                # Do NOT mark JSX files as auto-processed — LLM must still analyze them
                # for authentication, authorization, IDOR, XSS, and other logic-level issues.

            # Run automated source-code secret scanning across all scannable files.
            # This deterministically catches hardcoded API keys, tokens, passwords, and
            # connection strings that the LLM might miss due to stochastic output.
            secret_scan_paths = [
                p for p in file_list
                if not _TEST_PATH_RE_INLINE.search(p.replace("\\", "/"))
            ]
            if secret_scan_paths:
                abs_secret = [str(Path(base_for_rel) / p) for p in secret_scan_paths]
                secret_file_contents = get_file_contents(abs_secret)
                rel_secret_contents = {
                    rel: secret_file_contents.get(abs_p, "")
                    for rel, abs_p in zip(secret_scan_paths, abs_secret)
                }
                secret_findings, secret_error = scan_source_secrets(rel_secret_contents)
                if secret_error:
                    response["secretScanError"] = secret_error
                if secret_findings:
                    normalized = [_normalize_finding(f) for f in secret_findings]
                    stats = security_store.add_findings(scan_id, normalized)
                    response["secretScanResults"] = {
                        "found": len(secret_findings),
                        "added": stats.get("added", 0),
                        "note": (
                            f"Automated secret scan found {len(secret_findings)} hardcoded "
                            "secret(s) in source files. Auto-submitted."
                        ),
                    }
                # Do NOT mark as auto-processed — LLM must still analyze these files
                # for context-dependent issues beyond what regex can detect.

            # Run deterministic pattern scanner across all non-test source files.
            # Catches structural patterns (mass assignment, eval, insecure cookies,
            # dangerous functions, etc.) that are 100% consistent across every run.
            pattern_scan_paths = [
                p for p in file_list
                if not _TEST_PATH_RE_INLINE.search(p.replace("\\", "/"))
            ]
            if pattern_scan_paths:
                abs_pattern = [str(Path(base_for_rel) / p) for p in pattern_scan_paths]
                pattern_file_contents = get_file_contents(abs_pattern)
                rel_pattern_contents = {
                    rel: pattern_file_contents.get(abs_p, "")
                    for rel, abs_p in zip(pattern_scan_paths, abs_pattern)
                }
                pattern_findings, pattern_error = scan_patterns(rel_pattern_contents)
                if pattern_error:
                    response["patternScanError"] = pattern_error
                if pattern_findings:
                    normalized = [_normalize_finding(f) for f in pattern_findings]
                    stats = security_store.add_findings(scan_id, normalized)
                    response["patternScanResults"] = {
                        "found": len(pattern_findings),
                        "added": stats.get("added", 0),
                        "note": (
                            f"Deterministic pattern scan found {len(pattern_findings)} "
                            "issue(s) via regex rules. Auto-submitted."
                        ),
                    }
    else:
        response["nextStep"] = (
            "Call again with include_content=true, offset=0 to fetch file contents. "
            "Pass findings=[...] alongside subsequent offsets to submit findings as you go. "
            "Set is_last=true on the final call to finalize."
        )

    return [TextContent(type="text", text=json.dumps(response, indent=2))]


async def handle_submit_security_findings(
    security_store: SecurityScanStore,
    arguments: dict,
) -> list[TextContent]:
    """Handle the submit_security_findings tool call (kept for backward compat)."""
    scan_id = arguments.get("scan_id") or arguments.get("scanId")
    if not scan_id:
        return [TextContent(type="text", text="Error: scan_id is required")]

    raw_findings = arguments.get("findings") or []
    if isinstance(raw_findings, dict):
        raw_findings = [raw_findings]
    if not isinstance(raw_findings, list):
        return [TextContent(type="text", text="Error: findings must be a list")]

    stored = security_store.get(scan_id)
    if not stored:
        return [TextContent(type="text", text=f"Error: scan not found: {scan_id}")]
    if stored.status == "completed":
        return [TextContent(type="text", text=f"Error: scan already finalized: {scan_id}")]

    findings: list[SecurityFinding] = []
    for raw in raw_findings:
        if isinstance(raw, dict):
            findings.append(_normalize_finding(raw))

    stats = security_store.add_findings(scan_id, findings)

    is_last = arguments.get("is_last")
    if is_last is None:
        is_last = arguments.get("isLast")
    is_last = bool(is_last)

    summary = None
    if is_last:
        stored = security_store.finalize_scan(scan_id)
        if stored:
            summary = stored.result.summary.model_dump(by_alias=True)

    response = {
        "scanId": scan_id,
        "added": stats.get("added", 0),
        "deduped": stats.get("deduped", 0),
        "isLast": is_last,
        "status": "completed" if is_last else "pending",
        "summary": summary,
        "nextStep": (
            "Scan finalized. Call push_to_maeris with data_type='vulnerabilities' and scan_id."
            if is_last else "Submit more findings or set is_last=true to finalize."
        ),
    }

    return [TextContent(type="text", text=json.dumps(response, indent=2))]


async def handle_get_security_scans(
    security_store: SecurityScanStore,
    arguments: dict,
) -> list[TextContent]:
    """Handle the get_security_scans tool call."""
    scan_id = arguments.get("scan_id")
    last_n = arguments.get("last_n")
    nth = arguments.get("nth")
    result: Any

    if scan_id:
        stored = security_store.get(scan_id)
        if not stored:
            return [TextContent(type="text", text=f"Error: scan not found: {scan_id}")]
        result = _build_response(stored)
    elif nth:
        recent = security_store.get_recent(nth)
        if len(recent) < nth:
            return [TextContent(
                type="text",
                text=f"Error: requested nth={nth} but only {len(recent)} scan(s) available.",
            )]
        result = _build_response(recent[nth - 1])
    elif last_n:
        recent = security_store.get_recent(last_n)
        result = [_build_response(s) for s in recent]
    else:
        result = [_build_response(s) for s in security_store.get_recent(100)]

    return [TextContent(type="text", text=json.dumps(result, indent=2))]


async def handle_clear_security_scans(
    security_store: SecurityScanStore,
    arguments: dict,
) -> list[TextContent]:
    """Handle the clear_security_scans tool call."""
    scan_id = arguments.get("scan_id")

    if scan_id:
        deleted = security_store.delete(scan_id)
        response = {
            "success": deleted,
            "message": f"Deleted scan: {scan_id}" if deleted else f"Scan not found: {scan_id}",
        }
    else:
        count = security_store.clear()
        response = {
            "success": True,
            "message": f"Cleared {count} scan(s)",
            "count": count,
        }

    return [TextContent(type="text", text=json.dumps(response, indent=2))]


def _build_response(scan: StoredSecurityScan, include_findings: bool = True) -> dict:
    result = {
        "scanId": scan.result.scan_id,
        "targetPath": scan.target_path,
        "scanTimestamp": scan.result.scan_timestamp,
        "storedAt": scan.stored_at,
        "status": scan.status,
        "scanProfile": scan.scan_profile,
        "selectedChecks": scan.selected_checks,
        "filesScanned": scan.result.summary.files_scanned,
        "pushedToMaeris": scan.pushed_to_maeris,
        "pushedAt": scan.pushed_at,
        "summary": scan.result.summary.model_dump(by_alias=True),
    }
    if include_findings:
        result["findings"] = [f.model_dump(by_alias=True) for f in scan.result.findings]
    return result
