"""
InsAIts SDK - Security Utilities
=================================
Centralized security controls for SOC 2 compliance.

All input validation, credential handling, path sanitization,
URL validation, and audit logging lives here. Every module
imports from this single source.

SOC 2 Controls Addressed:
- CC6.1: Logical access / credential protection
- CC8.1: Input validation
- CC7.2: System monitoring (audit logging)
- CC5.2: Encryption at rest
"""

import hashlib
import ipaddress
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import urlparse

logger = logging.getLogger(__name__)

# ============================================
# Security Constants
# ============================================

MAX_MESSAGE_LENGTH = 1_000_000  # 1 MB
MAX_AGENT_ID_LENGTH = 255
MAX_JSON_SIZE = 50_000  # 50 KB for parsed JSON responses
MAX_JSON_DEPTH = 10
ALLOWED_ID_PATTERN = re.compile(r'^[a-zA-Z0-9_\-:.@]+$')
ALLOWED_API_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9_\-]{8,128}$')

# Private IP ranges for SSRF protection (RFC 1918 + link-local + loopback)
_PRIVATE_NETWORKS = [
    ipaddress.ip_network('10.0.0.0/8'),
    ipaddress.ip_network('172.16.0.0/12'),
    ipaddress.ip_network('192.168.0.0/16'),
    ipaddress.ip_network('169.254.0.0/16'),  # Link-local
    ipaddress.ip_network('127.0.0.0/8'),      # Loopback
    ipaddress.ip_network('::1/128'),           # IPv6 loopback
    ipaddress.ip_network('fc00::/7'),          # IPv6 private
    ipaddress.ip_network('fe80::/10'),         # IPv6 link-local
]

# Encryption availability (graceful degradation)
try:
    from cryptography.fernet import Fernet
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
    import base64
    CRYPTO_AVAILABLE = True
except ImportError:
    CRYPTO_AVAILABLE = False
    logger.warning(
        "cryptography library not installed. Cache encryption disabled. "
        "Install with: pip install cryptography"
    )


# ============================================
# Input Validation
# ============================================

def validate_agent_id(agent_id: str) -> str:
    """
    Validate agent/sender/receiver/llm IDs.

    Allows: alphanumeric, underscore, hyphen, colon, dot, @
    Max length: 255 characters.

    Raises:
        ValueError: If ID is invalid.
    """
    if not isinstance(agent_id, str):
        raise ValueError("Agent ID must be a string")

    if not agent_id or len(agent_id) > MAX_AGENT_ID_LENGTH:
        raise ValueError(
            f"Agent ID must be 1-{MAX_AGENT_ID_LENGTH} characters, "
            f"got {len(agent_id) if agent_id else 0}"
        )

    if not ALLOWED_ID_PATTERN.match(agent_id):
        raise ValueError(
            f"Agent ID contains invalid characters. "
            f"Allowed: alphanumeric, underscore, hyphen, colon, dot, @"
        )

    return agent_id


def validate_message_text(text: str, max_length: int = MAX_MESSAGE_LENGTH) -> str:
    """
    Validate and sanitize message text.

    Strips null bytes and most control characters (keeps newline, tab, carriage return).
    Enforces maximum length.

    Raises:
        ValueError: If text is invalid or too long.
    """
    if not isinstance(text, str):
        raise ValueError("Message text must be a string")

    if len(text) > max_length:
        raise ValueError(
            f"Message text exceeds maximum length of {max_length} characters"
        )

    # Strip null bytes and control characters (keep \n, \r, \t)
    cleaned = ''.join(
        ch for ch in text
        if ch in ('\n', '\r', '\t') or (ord(ch) >= 32)
    )

    if not cleaned:
        raise ValueError("Message text is empty after sanitization")

    return cleaned


def validate_api_key_format(api_key: str) -> bool:
    """
    Validate API key format (not validity — that's the server's job).

    Returns True if format is acceptable. Does not raise.
    """
    if not isinstance(api_key, str):
        return False
    return bool(ALLOWED_API_KEY_PATTERN.match(api_key))


# ============================================
# URL Validation (SSRF Protection)
# ============================================

def _is_private_ip(hostname: str) -> bool:
    """Check if hostname resolves to a private/internal IP."""
    # Check direct hostname matches
    if hostname in ('localhost', 'localhost.localdomain'):
        return True

    try:
        addr = ipaddress.ip_address(hostname)
        return any(addr in network for network in _PRIVATE_NETWORKS)
    except ValueError:
        # Not an IP address — it's a hostname. We can't resolve it here
        # without making a network call, so we only block known patterns.
        return False


def validate_url(url: str, allow_localhost: bool = False) -> str:
    """
    Validate URL for SSRF protection.

    Blocks:
    - Private IP ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x)
    - Loopback (127.x, ::1, localhost) unless allow_localhost=True
    - Non-HTTPS schemes for external URLs (HTTP allowed for localhost only)
    - URLs without hostname
    - File:// and other dangerous schemes

    Args:
        url: URL to validate
        allow_localhost: If True, allows localhost URLs (for Ollama etc.)

    Raises:
        ValueError: If URL is invalid or targets internal resources.
    """
    if not isinstance(url, str) or not url.strip():
        raise ValueError("URL must be a non-empty string")

    parsed = urlparse(url)

    # Block dangerous schemes
    if parsed.scheme not in ('http', 'https'):
        raise ValueError(
            f"URL scheme '{parsed.scheme}' not allowed. Use http or https."
        )

    hostname = parsed.hostname
    if not hostname:
        raise ValueError("URL must have a hostname")

    is_local = _is_private_ip(hostname)

    if is_local and not allow_localhost:
        raise ValueError(
            "URL targets a private/internal address. "
            "External webhooks must use public HTTPS endpoints."
        )

    # Require HTTPS for non-localhost URLs
    if not is_local and parsed.scheme != 'https':
        raise ValueError(
            "External URLs must use HTTPS. "
            f"Got: {parsed.scheme}://{hostname}"
        )

    return url


# ============================================
# Path Sanitization
# ============================================

def sanitize_path(filepath: str, base_dir: Path) -> Path:
    """
    Prevent path traversal attacks.

    Resolves the filepath, checks it stays within base_dir.
    Rejects symlinks that point outside base_dir.

    Args:
        filepath: User-provided file path (or just filename)
        base_dir: The directory the file must stay within

    Returns:
        Resolved, safe Path object

    Raises:
        ValueError: If path traversal detected.
    """
    if not isinstance(filepath, str) or not filepath.strip():
        raise ValueError("File path must be a non-empty string")

    base_dir = base_dir.resolve()

    # Use only the filename component to prevent directory traversal
    safe_name = Path(filepath).name

    if not safe_name or safe_name in ('.', '..'):
        raise ValueError("Invalid filename")

    target = (base_dir / safe_name).resolve()

    # Verify target is still within base_dir
    try:
        target.relative_to(base_dir)
    except ValueError:
        raise ValueError(
            f"Path traversal detected: resolved path is outside base directory"
        )

    return target


# ============================================
# Credential Handling
# ============================================

def mask_credential(value: str, visible_chars: int = 4) -> str:
    """
    Mask a credential for safe logging.

    Examples:
        'sk-abc123xyz789' -> 'sk-a***9789'
        'short' -> 's***t'
        '' -> '***'
    """
    if not value:
        return '***'

    if len(value) <= visible_chars * 2:
        # Too short to meaningfully mask — show first and last char only
        if len(value) <= 2:
            return '*' * len(value)
        return value[0] + '*' * (len(value) - 2) + value[-1]

    return value[:visible_chars] + '***' + value[-visible_chars:]


def hash_api_key(key: str, salt: Optional[bytes] = None) -> tuple:
    """
    Hash an API key using PBKDF2-SHA256 with 100K iterations.

    Much stronger than truncated SHA-256. Rainbow-table resistant.

    Args:
        key: The API key to hash
        salt: Optional salt bytes. If None, generates random 16-byte salt.

    Returns:
        Tuple of (hex_hash, salt_bytes) — store both.
    """
    if not key:
        return ("anonymous", b'')

    if salt is None:
        salt = os.urandom(16)

    if CRYPTO_AVAILABLE:
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA256(),
            length=32,
            salt=salt,
            iterations=100_000,
        )
        key_hash = kdf.derive(key.encode('utf-8'))
        return (key_hash.hex(), salt)
    else:
        # Fallback: HMAC-SHA256 with salt (weaker but better than plain SHA-256[:16])
        import hmac
        h = hmac.new(salt, key.encode('utf-8'), hashlib.sha256)
        return (h.hexdigest(), salt)


def verify_api_key_hash(key: str, expected_hash: str, salt: bytes) -> bool:
    """
    Verify an API key against a stored hash.

    Uses constant-time comparison to prevent timing attacks.
    """
    computed_hash, _ = hash_api_key(key, salt)
    return hmac_compare(computed_hash, expected_hash)


def hmac_compare(a: str, b: str) -> bool:
    """Constant-time string comparison to prevent timing attacks."""
    return hmac_safe_compare(a.encode('utf-8'), b.encode('utf-8'))


def hmac_safe_compare(a: bytes, b: bytes) -> bool:
    """Constant-time bytes comparison."""
    if len(a) != len(b):
        return False
    result = 0
    for x, y in zip(a, b):
        result |= x ^ y
    return result == 0


# ============================================
# Secure JSON Parsing
# ============================================

def safe_json_loads(
    text: str,
    max_size: int = MAX_JSON_SIZE,
    max_depth: int = MAX_JSON_DEPTH
) -> Optional[Dict]:
    """
    Parse JSON safely with size and depth limits.

    Returns None on any parsing failure instead of raising.
    Validates the result is a dict (not list, string, etc.).

    Args:
        text: JSON string to parse
        max_size: Maximum string length to accept
        max_depth: Maximum nesting depth

    Returns:
        Parsed dict, or None if invalid/too large/too deep.
    """
    if not isinstance(text, str):
        return None

    if len(text) > max_size:
        logger.warning(f"JSON too large: {len(text)} > {max_size} bytes")
        return None

    try:
        data = json.loads(text)
    except (json.JSONDecodeError, ValueError):
        return None

    if not isinstance(data, dict):
        logger.warning(f"Expected JSON dict, got {type(data).__name__}")
        return None

    # Check depth
    def _check_depth(obj: Any, current: int = 0) -> bool:
        if current > max_depth:
            return False
        if isinstance(obj, dict):
            return all(_check_depth(v, current + 1) for v in obj.values())
        if isinstance(obj, list):
            return all(_check_depth(v, current + 1) for v in obj)
        return True

    if not _check_depth(data):
        logger.warning(f"JSON exceeds max depth of {max_depth}")
        return None

    return data


# ============================================
# File Permission Security
# ============================================

def set_secure_file_permissions(path: Path) -> None:
    """
    Set file to owner-only read/write (0o600).

    On Windows, this is best-effort (Windows ACLs differ from POSIX).
    """
    try:
        if os.name == 'nt':
            # Windows: best-effort using icacls to remove inheritance
            # Full ACL control would need pywin32 — too heavy as dependency
            pass
        else:
            os.chmod(str(path), 0o600)
    except OSError as e:
        logger.warning(f"Could not set file permissions on {path.name}: {type(e).__name__}")


def set_secure_dir_permissions(path: Path) -> None:
    """
    Set directory to owner-only access (0o700).
    """
    try:
        if os.name != 'nt':
            os.chmod(str(path), 0o700)
    except OSError as e:
        logger.warning(f"Could not set directory permissions on {path.name}: {type(e).__name__}")


# ============================================
# Cache Encryption
# ============================================

def get_or_create_encryption_key(key_path: Path) -> Optional[bytes]:
    """
    Get or create a Fernet encryption key for local cache.

    Key is stored in a file with restricted permissions.
    Returns None if cryptography is not available.
    """
    if not CRYPTO_AVAILABLE:
        return None

    key_path.parent.mkdir(parents=True, exist_ok=True)
    set_secure_dir_permissions(key_path.parent)

    if key_path.exists():
        try:
            key = key_path.read_bytes().strip()
            # Validate it's a valid Fernet key
            Fernet(key)
            return key
        except Exception:
            logger.warning("Corrupted encryption key, regenerating")

    # Generate new key
    key = Fernet.generate_key()
    key_path.write_bytes(key)
    set_secure_file_permissions(key_path)
    return key


def encrypt_data(data: bytes, key: bytes) -> bytes:
    """Encrypt bytes using Fernet. Returns encrypted bytes."""
    if not CRYPTO_AVAILABLE:
        return data
    f = Fernet(key)
    return f.encrypt(data)


def decrypt_data(data: bytes, key: bytes) -> Optional[bytes]:
    """Decrypt bytes using Fernet. Returns None on failure."""
    if not CRYPTO_AVAILABLE:
        return data
    try:
        f = Fernet(key)
        return f.decrypt(data)
    except Exception:
        return None


# ============================================
# Audit Logger
# ============================================

class AuditLogger:
    """
    Structured security event logger for SOC 2 compliance.

    Logs security-relevant events with timestamps, session IDs,
    and event details. Never logs raw credentials.

    Events: auth_success, auth_failure, rate_limit_exceeded,
    export_performed, validation_error, ssrf_blocked,
    anomaly_detected, session_start, session_end.
    """

    def __init__(self, session_id: str = "", enabled: bool = True):
        self.session_id = session_id
        self.enabled = enabled
        self._logger = logging.getLogger('insaits.audit')

    def log(self, event_type: str, details: Optional[Dict] = None) -> None:
        """Log a security event."""
        if not self.enabled:
            return

        event = {
            "timestamp": time.time(),
            "iso_time": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
            "session_id": self.session_id,
            "event": event_type,
            "details": details or {}
        }

        self._logger.info(json.dumps(event, default=str))

    def auth_success(self, tier: str) -> None:
        self.log("auth_success", {"tier": tier})

    def auth_failure(self, reason: str) -> None:
        self.log("auth_failure", {"reason": reason})

    def rate_limit_exceeded(self, agent_id: str) -> None:
        self.log("rate_limit_exceeded", {"agent_id": agent_id})

    def export_performed(self, export_type: str, destination: str) -> None:
        self.log("export_performed", {
            "type": export_type,
            "destination": mask_credential(destination) if '/' in destination else destination
        })

    def validation_error(self, field: str, reason: str) -> None:
        self.log("validation_error", {"field": field, "reason": reason})

    def ssrf_blocked(self, url: str) -> None:
        # Mask the URL but show the hostname
        parsed = urlparse(url)
        self.log("ssrf_blocked", {"hostname": parsed.hostname or "unknown"})

    def anomaly_detected(self, anomaly_type: str, severity: str) -> None:
        self.log("anomaly_detected", {"type": anomaly_type, "severity": severity})
