"""Security validation utilities for remediation commands and resources."""

from __future__ import annotations

import asyncio
import ipaddress
import os
import re
import shlex
import socket
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import structlog

log = structlog.get_logger(__name__)

# Characters that are dangerous in shell commands - block BEFORE shlex parsing
DANGEROUS_SHELL_CHARS = frozenset('`$(){}[]|;&<>!\\"\'\n\r\x00')

# Pattern for safe command arguments (alphanumeric, underscores, hyphens, dots, forward slashes)
SAFE_ARG_PATTERN = re.compile(r"^[a-zA-Z0-9_\-./=:@%,+]+$")

# Allowed executables for commands (basename only - will be resolved to full path)
DEFAULT_ALLOWED_EXECUTABLES = frozenset(
    {
        # System control
        "systemctl",
        "service",
        # Process inspection
        "pgrep",
        "pidof",
        "ps",
        # Network utilities
        "curl",
        "wget",
        # File operations (read-only)
        "cat",
        "head",
        "tail",
        "grep",
        # Signal handling
        "kill",
        "pkill",
    }
)

# Internal/private IP ranges to block (SSRF prevention)
BLOCKED_IP_NETWORKS = [
    ipaddress.ip_network("10.0.0.0/8"),  # Private Class A
    ipaddress.ip_network("172.16.0.0/12"),  # Private Class B
    ipaddress.ip_network("192.168.0.0/16"),  # Private Class C
    ipaddress.ip_network("127.0.0.0/8"),  # Loopback
    ipaddress.ip_network("169.254.0.0/16"),  # Link-local
    ipaddress.ip_network("0.0.0.0/8"),  # This network
    ipaddress.ip_network("100.64.0.0/10"),  # Carrier-grade NAT
    ipaddress.ip_network("192.0.0.0/24"),  # IETF Protocol Assignments
    ipaddress.ip_network("192.0.2.0/24"),  # TEST-NET-1
    ipaddress.ip_network("198.51.100.0/24"),  # TEST-NET-2
    ipaddress.ip_network("203.0.113.0/24"),  # TEST-NET-3
    ipaddress.ip_network("224.0.0.0/4"),  # Multicast
    ipaddress.ip_network("240.0.0.0/4"),  # Reserved
    ipaddress.ip_network("255.255.255.255/32"),  # Broadcast
    # IPv6
    ipaddress.ip_network("::1/128"),  # Loopback
    ipaddress.ip_network("::/128"),  # Unspecified
    ipaddress.ip_network("fc00::/7"),  # Unique local
    ipaddress.ip_network("fe80::/10"),  # Link-local
    ipaddress.ip_network("ff00::/8"),  # Multicast
]


class SecurityError(Exception):
    """Security validation error."""

    pass


@dataclass
class ParsedCommand:
    """Result of command parsing."""

    executable: str
    args: list[str]
    original: str


@dataclass
class ValidatedURL:
    """Result of URL validation with resolved IP."""

    url: str
    scheme: str
    hostname: str
    port: int | None
    resolved_ip: str | None = None


def parse_command_safe(command: str) -> ParsedCommand:
    """
    Safely parse a command string into executable and arguments.

    Security measures:
    1. Block dangerous shell metacharacters BEFORE parsing
    2. Use shlex to properly handle quoted arguments
    3. Validate each argument against safe pattern

    Args:
        command: Command string to parse

    Returns:
        ParsedCommand with executable and arguments

    Raises:
        SecurityError: If command contains dangerous characters or patterns
    """
    if not command or not command.strip():
        raise SecurityError("Empty command")

    # Step 1: Block dangerous shell metacharacters FIRST
    # This prevents shell injection attacks before shlex can misparse them
    for char in command:
        if char in DANGEROUS_SHELL_CHARS:
            raise SecurityError(
                f"Command contains dangerous character: {repr(char)}"
            )

    # Step 2: Parse with shlex
    try:
        parts = shlex.split(command)
    except ValueError as e:
        raise SecurityError(f"Invalid command syntax: {e}") from e

    if not parts:
        raise SecurityError("Empty command after parsing")

    executable = parts[0]
    args = parts[1:]

    # Step 3: Validate each argument
    for arg in args:
        if not SAFE_ARG_PATTERN.match(arg):
            # Allow file paths with spaces if quoted (shlex handles this)
            # But reject anything that looks like command injection
            if any(c in DANGEROUS_SHELL_CHARS for c in arg):
                raise SecurityError(f"Argument contains dangerous characters: {arg}")

    return ParsedCommand(executable=executable, args=args, original=command)


def validate_executable(
    executable: str,
    allowed_executables: frozenset[str] | None = None,
) -> str:
    """
    Validate that an executable is in the allowed list.

    Args:
        executable: Executable name or path
        allowed_executables: Set of allowed executable basenames

    Returns:
        The validated executable path/name

    Raises:
        SecurityError: If executable is not allowed
    """
    allowed = allowed_executables or DEFAULT_ALLOWED_EXECUTABLES

    # Get basename for comparison
    basename = os.path.basename(executable)

    if basename not in allowed:
        raise SecurityError(f"Executable not allowed: {executable}")

    # If it's just a basename, return as-is (let PATH resolve it)
    # If it's a full path, verify the basename matches
    if "/" in executable:
        # Full path provided - verify basename is allowed
        if basename not in allowed:
            raise SecurityError(f"Executable path not allowed: {executable}")

    return executable


async def execute_command_safe(
    command: str,
    timeout: float = 30.0,
    allowed_executables: frozenset[str] | None = None,
    env: dict[str, str] | None = None,
) -> tuple[int, str, str]:
    """
    Safely execute a command using subprocess_exec (not shell).

    Security measures:
    1. Parse command safely, blocking dangerous characters
    2. Validate executable against allowlist
    3. Use create_subprocess_exec (no shell interpretation)
    4. Apply timeout to prevent hanging

    Args:
        command: Command string to execute
        timeout: Maximum execution time in seconds
        allowed_executables: Optional custom allowlist
        env: Optional environment variables

    Returns:
        Tuple of (exit_code, stdout, stderr)

    Raises:
        SecurityError: If command validation fails
        asyncio.TimeoutError: If command times out
    """
    # Parse and validate command
    parsed = parse_command_safe(command)
    executable = validate_executable(parsed.executable, allowed_executables)

    log.debug(
        "executing_command_safe",
        executable=executable,
        args=parsed.args,
    )

    # Execute without shell
    proc = await asyncio.create_subprocess_exec(
        executable,
        *parsed.args,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        env=env,
    )

    try:
        stdout, stderr = await asyncio.wait_for(
            proc.communicate(),
            timeout=timeout,
        )
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        raise

    exit_code = proc.returncode or 0
    stdout_str = stdout.decode("utf-8", errors="replace")
    stderr_str = stderr.decode("utf-8", errors="replace")

    return exit_code, stdout_str, stderr_str


def is_internal_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
    """
    Check if an IP address is in an internal/private/blocked range.

    Args:
        ip: IP address to check

    Returns:
        True if IP is internal/blocked
    """
    for network in BLOCKED_IP_NETWORKS:
        try:
            if ip in network:
                return True
        except TypeError:
            # IPv4/IPv6 mismatch - skip
            continue
    return False


def parse_ip_address(addr_str: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
    """
    Parse an IP address string, handling various formats.

    Handles:
    - Standard IPv4: 192.168.1.1
    - Standard IPv6: ::1
    - IPv4-mapped IPv6: ::ffff:127.0.0.1
    - Hex IP: 0x7f000001

    Args:
        addr_str: IP address string

    Returns:
        Parsed IP address or None if invalid
    """
    # Try standard parsing first
    try:
        ip = ipaddress.ip_address(addr_str)
        # If it's an IPv4-mapped IPv6 address, extract the IPv4 part
        if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
            return ip.ipv4_mapped
        return ip
    except ValueError:
        pass

    # Try hex format (0x7f000001 = 127.0.0.1)
    if addr_str.lower().startswith("0x"):
        try:
            ip_int = int(addr_str, 16)
            return ipaddress.IPv4Address(ip_int)
        except (ValueError, ipaddress.AddressValueError):
            pass

    # Try octal format (0177.0.0.1)
    if addr_str.startswith("0") and "." in addr_str:
        try:
            parts = addr_str.split(".")
            decimal_parts = [str(int(p, 8)) for p in parts]
            return ipaddress.IPv4Address(".".join(decimal_parts))
        except (ValueError, ipaddress.AddressValueError):
            pass

    return None


def validate_url_security(
    url: str,
    allowed_hosts: list[str] | None = None,
    resolve_dns: bool = True,
    allowed_schemes: frozenset[str] | None = None,
) -> ValidatedURL:
    """
    Validate a URL against security policies.

    Security measures:
    1. Only allow http/https schemes
    2. Block internal/private IP addresses
    3. Resolve DNS and check resolved IPs
    4. Match against explicit hostname allowlist

    Args:
        url: URL to validate
        allowed_hosts: List of allowed hostnames/patterns (supports wildcards)
        resolve_dns: Whether to resolve and validate DNS
        allowed_schemes: Allowed URL schemes (default: http, https)

    Returns:
        ValidatedURL with resolved information

    Raises:
        SecurityError: If URL validation fails
    """
    schemes = allowed_schemes or frozenset({"http", "https"})

    try:
        parsed = urlparse(url)
    except Exception as e:
        raise SecurityError(f"Invalid URL: {e}") from e

    # Check scheme
    if parsed.scheme not in schemes:
        raise SecurityError(
            f"URL scheme not allowed: {parsed.scheme} (allowed: {', '.join(schemes)})"
        )

    hostname = parsed.hostname
    if not hostname:
        raise SecurityError("URL has no hostname")

    port = parsed.port

    resolved_ip: str | None = None

    # Check if hostname is already an IP address
    ip = parse_ip_address(hostname)
    if ip is not None:
        if is_internal_ip(ip):
            raise SecurityError(f"IP address is internal/blocked: {hostname}")
        resolved_ip = str(ip)
    elif resolve_dns:
        # Resolve hostname and check IPs
        try:
            # Get all resolved addresses
            addr_infos = socket.getaddrinfo(
                hostname,
                port or (443 if parsed.scheme == "https" else 80),
                socket.AF_UNSPEC,
                socket.SOCK_STREAM,
            )

            for family, _, _, _, addr in addr_infos:
                addr_str = addr[0]
                ip = parse_ip_address(addr_str)
                if ip is not None and is_internal_ip(ip):
                    raise SecurityError(
                        f"Hostname {hostname} resolves to internal IP: {addr_str}"
                    )

            # Store first resolved IP for later use (DNS rebinding prevention)
            if addr_infos:
                resolved_ip = addr_infos[0][4][0]

        except socket.gaierror as e:
            raise SecurityError(f"Cannot resolve hostname {hostname}: {e}") from e

    # Check against allowlist
    if allowed_hosts:
        import fnmatch

        hostname_allowed = False
        for pattern in allowed_hosts:
            if hostname == pattern or fnmatch.fnmatch(hostname, pattern):
                hostname_allowed = True
                break

        if not hostname_allowed:
            raise SecurityError(
                f"Hostname not in allowed hosts: {hostname}"
            )

    return ValidatedURL(
        url=url,
        scheme=parsed.scheme,
        hostname=hostname,
        port=port,
        resolved_ip=resolved_ip,
    )


def validate_path_security(
    path: str,
    allowed_prefixes: list[str],
    must_exist: bool = False,
    must_be_file: bool = True,
) -> str:
    """
    Validate a file path against security policies.

    Security measures:
    1. Reject paths containing ".." before resolution
    2. Resolve symlinks using os.path.realpath()
    3. Resolve allowed prefixes too (they might contain symlinks)
    4. Check that resolved path starts with an allowed prefix
    5. Optionally verify the path exists and is a regular file

    Args:
        path: File path to validate
        allowed_prefixes: List of allowed directory prefixes (supports globs)
        must_exist: Whether path must exist
        must_be_file: Whether path must be a regular file (not dir/device/etc)

    Returns:
        The resolved absolute path

    Raises:
        SecurityError: If path validation fails
    """
    # Early rejection of suspicious patterns
    if ".." in path:
        raise SecurityError("Path contains directory traversal: ..")

    if path.startswith("~"):
        raise SecurityError("Path contains home directory expansion: ~")

    if "\x00" in path:
        raise SecurityError("Path contains null byte")

    try:
        # Resolve to absolute path, following all symlinks
        resolved = os.path.realpath(path)
    except (OSError, ValueError) as e:
        raise SecurityError(f"Cannot resolve path: {e}") from e

    # Check against allowed prefixes
    path_allowed = False
    for pattern in allowed_prefixes:
        # Resolve the prefix too (it might contain symlinks)
        # Handle glob patterns like "/var/log/**"
        if "**" in pattern:
            prefix = pattern.split("**")[0].rstrip("/")
        elif "*" in pattern:
            # For patterns like "/var/log/*.log", use parent directory
            prefix = os.path.dirname(pattern.replace("*", ""))
        else:
            prefix = pattern

        try:
            resolved_prefix = os.path.realpath(prefix)
        except (OSError, ValueError):
            continue

        # Strict prefix check
        if resolved.startswith(resolved_prefix + os.sep) or resolved == resolved_prefix:
            path_allowed = True
            break

    if not path_allowed:
        raise SecurityError(
            f"Path not in allowed directories: {path} (resolved: {resolved})"
        )

    # Check existence if required
    if must_exist or must_be_file:
        if not os.path.exists(resolved):
            if must_exist:
                raise SecurityError(f"Path does not exist: {resolved}")
        else:
            # Path exists - check if it's a regular file
            if must_be_file and not os.path.isfile(resolved):
                raise SecurityError(f"Path is not a regular file: {resolved}")

    return resolved


async def read_file_tail(
    path: str,
    max_lines: int = 10000,
    max_line_length: int = 10000,
) -> list[str]:
    """
    Read the last N lines of a file without loading the entire file into memory.

    Uses a deque with maxlen to maintain only the last N lines as we iterate.

    Args:
        path: File path to read
        max_lines: Maximum number of lines to return
        max_line_length: Maximum length per line (truncate longer lines)

    Returns:
        List of the last max_lines lines from the file
    """
    from collections import deque

    lines: deque[str] = deque(maxlen=max_lines)

    def _read_tail() -> list[str]:
        with open(path, errors="replace") as f:
            for line in f:
                # Truncate long lines
                if len(line) > max_line_length:
                    line = line[:max_line_length] + "...[truncated]"
                lines.append(line.rstrip("\n\r"))
        return list(lines)

    # Run in thread to not block event loop
    return await asyncio.to_thread(_read_tail)


def compile_regex_safe(
    pattern: str,
    timeout_seconds: float = 1.0,
) -> re.Pattern[str] | Callable[[str], re.Match[str] | None]:
    """
    Compile a regex pattern with ReDoS protection.

    If the 're2' library is available, use it for linear-time guarantees.
    Otherwise, wrap the standard regex with a timeout mechanism.

    Args:
        pattern: Regex pattern to compile
        timeout_seconds: Maximum time for regex operations

    Returns:
        A compiled pattern or a callable that applies the pattern with timeout

    Raises:
        SecurityError: If pattern is invalid
    """
    # Try to use RE2 for guaranteed linear-time matching
    try:
        import re2

        return re2.compile(pattern)
    except ImportError:
        pass

    # Fall back to standard regex with pattern complexity check
    try:
        compiled = re.compile(pattern)
    except re.error as e:
        raise SecurityError(f"Invalid regex pattern: {e}") from e

    # Check for potentially catastrophic patterns
    # These heuristics catch common ReDoS patterns but aren't perfect
    dangerous_patterns = [
        r"(\w+)+",  # Nested quantifiers
        r"(a+)+",
        r"(.*)*",
        r"(a*)*",
        r"(a|aa)+",  # Overlapping alternations
        r"(a|a?)+",
    ]

    for dangerous in dangerous_patterns:
        if dangerous in pattern:
            log.warning(
                "potentially_dangerous_regex",
                pattern=pattern,
                reason="May cause catastrophic backtracking",
            )

    return compiled
