"""Strict input validation for all MCP tool parameters.

Every user-supplied value passes through validation before reaching the API.
Reject early, reject loud.
"""

from __future__ import annotations

import re

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_CVE_RE = re.compile(r"^CVE-\d{4}-\d{4,7}$")
_EIP_RE = re.compile(r"^EIP-\d{4}-\d{4,7}$")
_SEVERITIES = frozenset({"critical", "high", "medium", "low"})
_SORT_OPTIONS = frozenset({"newest", "oldest", "cvss_desc", "epss_desc", "relevance"})
_ECOSYSTEMS = frozenset({"npm", "pip", "maven", "go", "crates", "nuget", "rubygems", "composer"})
_ATTACK_TYPES = frozenset({"rce", "sqli", "xss", "dos", "lpe", "auth_bypass", "info_leak", "deserialization", "other"})
_COMPLEXITIES = frozenset({"trivial", "simple", "moderate", "complex"})
_RELIABILITIES = frozenset({"reliable", "unreliable", "untested", "theoretical"})
# Keep this list in sync with backend data sources surfaced by /api/v1/exploits.
_SOURCES = frozenset({
    "github",
    "metasploit",
    "exploitdb",
    "nomisec",
    "writeup",
    "gitlab",
    "inthewild",
    "vulncheck_xdb",
})
_LLM_CLASSIFICATIONS = frozenset({
    "working_poc",
    "trojan",
    "suspicious",
    "scanner",
    "stub",
    "writeup",
    "tool",
    "no_code",
})
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
_TECH_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9 ._-]{0,49}$")

MAX_QUERY_LEN = 200
MAX_STRING_LEN = 100
MAX_PER_PAGE = 25
MAX_EXPLOIT_ID = 2**31
MAX_CODE_SIZE = 50 * 1024  # 50 KB cap for code responses
MAX_TECHNOLOGIES = 5


class ValidationError(Exception):
    """Raised when input validation fails."""
    pass


# ---------------------------------------------------------------------------
# Sanitizers
# ---------------------------------------------------------------------------

def clean_string(value: str, max_len: int = MAX_STRING_LEN) -> str:
    """Strip control characters, reject null bytes, enforce length."""
    if not isinstance(value, str):
        raise ValidationError(f"Expected string, got {type(value).__name__}")
    if "\x00" in value:
        raise ValidationError("Null byte detected in input")
    cleaned = _CONTROL_CHARS.sub("", value).strip()
    if len(cleaned) > max_len:
        raise ValidationError(f"String exceeds {max_len} character limit")
    return cleaned


# ---------------------------------------------------------------------------
# CVE / EIP ID validators
# ---------------------------------------------------------------------------

def validate_vuln_id(raw: str) -> str:
    """Validate and normalize a vulnerability ID (CVE-ID or EIP-ID). Returns uppercase."""
    cleaned = clean_string(raw, max_len=20)
    normalized = cleaned.upper().replace("\u2010", "-").replace("\u2011", "-").replace("\u2013", "-").replace("\u2014", "-")
    if not _CVE_RE.match(normalized) and not _EIP_RE.match(normalized):
        raise ValidationError(
            f"Invalid vulnerability ID format: '{raw}'. "
            f"Expected: CVE-YYYY-NNNNN or EIP-YYYY-NNNNN (e.g. CVE-2024-3400)"
        )
    return normalized


def validate_cve_id(raw: str) -> str:
    """Validate and normalize a CVE ID specifically (for exploit search cve= filter)."""
    cleaned = clean_string(raw, max_len=20)
    normalized = cleaned.upper().replace("\u2010", "-").replace("\u2011", "-").replace("\u2013", "-").replace("\u2014", "-")
    if not _CVE_RE.match(normalized):
        raise ValidationError(
            f"Invalid CVE ID format: '{raw}'. Expected: CVE-YYYY-NNNNN (e.g. CVE-2024-3400)"
        )
    return normalized


# ---------------------------------------------------------------------------
# Numeric validators
# ---------------------------------------------------------------------------

def validate_exploit_id(raw) -> int:
    """Validate exploit ID as a positive integer."""
    try:
        val = int(raw)
    except (TypeError, ValueError):
        raise ValidationError(f"Exploit ID must be an integer, got: '{raw}'")
    if val < 1 or val > MAX_EXPLOIT_ID:
        raise ValidationError(f"Exploit ID out of range: {val}")
    return val


def validate_cvss(raw) -> float:
    """Validate CVSS score (0.0-10.0)."""
    try:
        val = float(raw)
    except (TypeError, ValueError):
        raise ValidationError(f"CVSS score must be a number, got: '{raw}'")
    if val < 0.0 or val > 10.0:
        raise ValidationError(f"CVSS score must be 0.0-10.0, got: {val}")
    return val


def validate_epss(raw) -> float:
    """Validate EPSS score (0.0-1.0)."""
    try:
        val = float(raw)
    except (TypeError, ValueError):
        raise ValidationError(f"EPSS score must be a number, got: '{raw}'")
    if val < 0.0 or val > 1.0:
        raise ValidationError(f"EPSS score must be 0.0-1.0, got: {val}")
    return val


def validate_year(raw) -> int:
    """Validate CVE year (1999 through current year + 1)."""
    try:
        val = int(raw)
    except (TypeError, ValueError):
        raise ValidationError(f"year must be an integer, got: '{raw}'")
    import datetime
    max_year = datetime.date.today().year + 1
    if val < 1999 or val > max_year:
        raise ValidationError(f"year must be 1999-{max_year}, got: {val}")
    return val


def validate_date(raw: str) -> str:
    """Validate date string (YYYY-MM-DD format)."""
    cleaned = clean_string(raw, max_len=10)
    if not re.match(r"^\d{4}-\d{2}-\d{2}$", cleaned):
        raise ValidationError(f"Invalid date format: '{raw}'. Expected: YYYY-MM-DD")
    return cleaned


def validate_page(raw) -> int:
    """Validate page number (1+)."""
    try:
        val = int(raw)
    except (TypeError, ValueError):
        raise ValidationError(f"page must be an integer, got: '{raw}'")
    if val < 1:
        raise ValidationError(f"page must be >= 1, got: {val}")
    return val


def validate_per_page(raw) -> int:
    """Validate per_page (1-25 for MCP, keep AI context manageable)."""
    try:
        val = int(raw)
    except (TypeError, ValueError):
        raise ValidationError(f"per_page must be an integer, got: '{raw}'")
    if val < 1 or val > MAX_PER_PAGE:
        raise ValidationError(f"per_page must be 1-{MAX_PER_PAGE}, got: {val}")
    return val


# ---------------------------------------------------------------------------
# Enum validators
# ---------------------------------------------------------------------------

def validate_severity(raw: str) -> str:
    """Validate severity is one of the allowed values."""
    cleaned = clean_string(raw).lower()
    if cleaned not in _SEVERITIES:
        raise ValidationError(
            f"Invalid severity: '{raw}'. Must be one of: {', '.join(sorted(_SEVERITIES))}"
        )
    return cleaned


def validate_sort(raw: str) -> str:
    """Validate sort option."""
    cleaned = clean_string(raw).lower()
    if cleaned not in _SORT_OPTIONS:
        raise ValidationError(
            f"Invalid sort: '{raw}'. Must be one of: {', '.join(sorted(_SORT_OPTIONS))}"
        )
    return cleaned


def validate_ecosystem(raw: str) -> str:
    """Validate ecosystem name."""
    cleaned = clean_string(raw).lower()
    if cleaned not in _ECOSYSTEMS:
        raise ValidationError(
            f"Invalid ecosystem: '{raw}'. Must be one of: {', '.join(sorted(_ECOSYSTEMS))}"
        )
    return cleaned


def validate_source(raw: str) -> str:
    """Validate exploit source name."""
    cleaned = clean_string(raw).lower()
    if cleaned not in _SOURCES:
        raise ValidationError(
            f"Invalid source: '{raw}'. Must be one of: {', '.join(sorted(_SOURCES))}"
        )
    return cleaned


def validate_language(raw: str) -> str:
    """Validate and clean exploit language. No strict allowlist (too many languages)."""
    return clean_string(raw, max_len=40).lower()


def validate_llm_classification(raw: str) -> str:
    """Validate LLM classification value."""
    cleaned = clean_string(raw).lower()
    if cleaned not in _LLM_CLASSIFICATIONS:
        raise ValidationError(
            f"Invalid llm_classification: '{raw}'. Must be one of: {', '.join(sorted(_LLM_CLASSIFICATIONS))}"
        )
    return cleaned


def validate_min_stars(raw) -> int:
    """Validate min_stars as a non-negative integer."""
    try:
        val = int(raw)
    except (TypeError, ValueError):
        raise ValidationError(f"min_stars must be an integer, got: '{raw}'")
    if val < 0:
        raise ValidationError(f"min_stars must be >= 0, got: {val}")
    return val


def validate_attack_type(raw: str) -> str:
    """Validate attack type from LLM analysis."""
    cleaned = clean_string(raw).lower()
    if cleaned not in _ATTACK_TYPES:
        raise ValidationError(
            f"Invalid attack_type: '{raw}'. Must be one of: {', '.join(sorted(_ATTACK_TYPES))}"
        )
    # API expects original casing for some values (RCE, SQLi, XSS, DoS, LPE)
    case_map = {"rce": "RCE", "sqli": "SQLi", "xss": "XSS", "dos": "DoS", "lpe": "LPE"}
    return case_map.get(cleaned, cleaned)


def validate_complexity(raw: str) -> str:
    """Validate exploit complexity from LLM analysis."""
    cleaned = clean_string(raw).lower()
    if cleaned not in _COMPLEXITIES:
        raise ValidationError(
            f"Invalid complexity: '{raw}'. Must be one of: {', '.join(sorted(_COMPLEXITIES))}"
        )
    return cleaned


def validate_reliability(raw: str) -> str:
    """Validate exploit reliability from LLM analysis."""
    cleaned = clean_string(raw).lower()
    if cleaned not in _RELIABILITIES:
        raise ValidationError(
            f"Invalid reliability: '{raw}'. Must be one of: {', '.join(sorted(_RELIABILITIES))}"
        )
    return cleaned


# ---------------------------------------------------------------------------
# String validators
# ---------------------------------------------------------------------------

def validate_query(raw: str) -> str:
    """Validate search query string."""
    return clean_string(raw, max_len=MAX_QUERY_LEN)


def validate_vendor(raw: str) -> str:
    """Validate vendor name."""
    return clean_string(raw, max_len=MAX_STRING_LEN)


def validate_product(raw: str) -> str:
    """Validate product name."""
    return clean_string(raw, max_len=MAX_STRING_LEN)


def validate_cwe(raw: str) -> str:
    """Validate CWE ID (accepts '79' or 'CWE-79')."""
    cleaned = clean_string(raw, max_len=12)
    stripped = cleaned.upper().replace("CWE-", "")
    if not stripped.isdigit():
        raise ValidationError(f"Invalid CWE ID: '{raw}'. Use format: 79 or CWE-79")
    return stripped


def validate_file_path(raw: str) -> str:
    """Validate file path — block traversal attacks."""
    cleaned = clean_string(raw, max_len=500)
    # Note: null bytes already rejected by clean_string()
    if ".." in cleaned:
        raise ValidationError("Path traversal blocked: '..' not allowed in file path")
    if cleaned.startswith("/") or cleaned.startswith("\\"):
        raise ValidationError("Absolute paths not allowed")
    if re.match(r"^[A-Za-z]:[\\/]", cleaned):
        raise ValidationError("Drive-letter absolute paths not allowed")
    if "~" in cleaned:
        raise ValidationError("Home directory expansion not allowed in file path")
    return cleaned


# ---------------------------------------------------------------------------
# Composite validators
# ---------------------------------------------------------------------------

def validate_technologies(raw: str) -> list[str]:
    """Validate comma-separated technology list for audit_stack."""
    cleaned = clean_string(raw, max_len=300)
    techs = [t.strip() for t in cleaned.split(",") if t.strip()]
    if not techs:
        raise ValidationError("At least one technology required")
    if len(techs) > MAX_TECHNOLOGIES:
        raise ValidationError(f"Maximum {MAX_TECHNOLOGIES} technologies allowed, got {len(techs)}")
    validated = []
    for t in techs:
        if not _TECH_NAME.match(t):
            raise ValidationError(
                f"Invalid technology name: '{t}'. Use alphanumeric, dots, hyphens, spaces only."
            )
        validated.append(t)
    return validated
