"""YAML-based security policy parsing, presets, and validation."""

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

import yaml

from hatchdx import HdxError
from hatchdx.sandbox.models import (
    FilesystemPolicy,
    NetworkPolicy,
    ResourceLimits,
    SandboxPolicy,
)


class PolicyError(HdxError):
    """Raised when a sandbox policy is invalid or cannot be loaded."""


# ---------------------------------------------------------------------------
# Built-in presets
# ---------------------------------------------------------------------------

PRESET_STRICT = SandboxPolicy(
    name="strict",
    network=NetworkPolicy(enabled=False, allowed_domains=[]),
    filesystem=FilesystemPolicy(read_only_mounts=["/app"], writable_dirs=[]),
    resources=ResourceLimits(cpu_limit="0.5", memory_limit="256m", timeout_seconds=15),
)

PRESET_STANDARD = SandboxPolicy(
    name="standard",
    network=NetworkPolicy(enabled=True, allowed_domains=[]),
    filesystem=FilesystemPolicy(
        read_only_mounts=["/app"],
        writable_dirs=["/tmp"],
    ),
    resources=ResourceLimits(cpu_limit="1.0", memory_limit="512m", timeout_seconds=30),
)

PRESET_PERMISSIVE = SandboxPolicy(
    name="permissive",
    network=NetworkPolicy(enabled=True, allowed_domains=[]),
    filesystem=FilesystemPolicy(
        read_only_mounts=[],
        writable_dirs=["/tmp", "/app/data"],
    ),
    resources=ResourceLimits(cpu_limit="2.0", memory_limit="1g", timeout_seconds=120),
)

_PRESETS: dict[str, SandboxPolicy] = {
    "strict": PRESET_STRICT,
    "standard": PRESET_STANDARD,
    "permissive": PRESET_PERMISSIVE,
}

VALID_PRESET_NAMES = frozenset(_PRESETS.keys())

# ---------------------------------------------------------------------------
# Domain validation
# ---------------------------------------------------------------------------

# Very permissive regex — allows wildcard prefix (*.example.com) and bare
# hostnames.  We intentionally keep this simple; full RFC 1123 compliance is
# overkill for a policy file.
_DOMAIN_RE = re.compile(
    r"^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$"
)

# Memory limit pattern — Docker notation (e.g. "256m", "1g", "512000k")
_MEMORY_RE = re.compile(r"^\d+[bBkKmMgG]?$")

# CPU limit pattern — decimal fraction (e.g. "0.5", "2.0", "1")
_CPU_RE = re.compile(r"^\d+(\.\d+)?$")


def _validate_domain(domain: str) -> bool:
    """Return True if *domain* is a plausible hostname or wildcard pattern."""
    return bool(_DOMAIN_RE.match(domain))


def _validate_memory_limit(value: str) -> bool:
    """Return True if *value* is a valid Docker memory limit string."""
    return bool(_MEMORY_RE.match(value))


def _validate_cpu_limit(value: str) -> bool:
    """Return True if *value* is a valid fractional CPU string."""
    if not _CPU_RE.match(value):
        return False
    return float(value) > 0


def _validate_timeout(value: int) -> bool:
    """Return True if *value* is a sensible timeout in seconds."""
    return isinstance(value, int) and 1 <= value <= 3600


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def load_preset(name: str) -> SandboxPolicy:
    """Return a deep copy of the named built-in preset.

    Raises ``PolicyError`` if the preset name is not recognised.
    """
    if name not in _PRESETS:
        valid = ", ".join(sorted(VALID_PRESET_NAMES))
        raise PolicyError(
            f"Unknown policy preset: '{name}'. Valid presets: {valid}"
        )
    preset = _PRESETS[name]
    # Return a fresh copy so callers can mutate without affecting the originals.
    return SandboxPolicy(
        name=preset.name,
        network=NetworkPolicy(
            enabled=preset.network.enabled,
            allowed_domains=list(preset.network.allowed_domains),
        ),
        filesystem=FilesystemPolicy(
            read_only_mounts=list(preset.filesystem.read_only_mounts),
            writable_dirs=list(preset.filesystem.writable_dirs),
        ),
        resources=ResourceLimits(
            cpu_limit=preset.resources.cpu_limit,
            memory_limit=preset.resources.memory_limit,
            timeout_seconds=preset.resources.timeout_seconds,
        ),
    )


def load_policy(source: str | Path) -> SandboxPolicy:
    """Load a sandbox policy from a YAML string or file path.

    If *source* is a ``Path`` (or a string path ending in ``.yaml`` / ``.yml``
    that exists on disk), the file is read and parsed.  Otherwise *source* is
    treated as raw YAML text.

    The YAML may specify a ``policy`` key naming a preset.  Any additional
    keys (``network``, ``filesystem``, ``resources``) override the preset
    defaults.  If no ``policy`` key is given, ``"standard"`` is assumed.

    Raises ``PolicyError`` on parse or validation failures.
    """
    raw = _load_raw_yaml(source)
    return _parse_policy(raw)


def load_policy_from_yaml(yaml_text: str) -> SandboxPolicy:
    """Parse a policy directly from a YAML string.

    Convenience wrapper around ``load_policy`` for callers that already have
    the text in hand (e.g. tests, inline config).
    """
    try:
        raw = yaml.safe_load(yaml_text)
    except yaml.YAMLError as exc:
        raise PolicyError(f"Invalid YAML: {exc}") from exc

    if raw is None:
        raw = {}

    if not isinstance(raw, dict):
        raise PolicyError(
            f"Policy YAML must be a mapping, got {type(raw).__name__}"
        )

    return _parse_policy(raw)


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _load_raw_yaml(source: str | Path) -> dict[str, Any]:
    """Read YAML from *source* (file path or raw text) and return a dict."""
    path = Path(source) if isinstance(source, Path) else None

    # Heuristic: if source is a string ending in .yaml/.yml and that file
    # exists, treat it as a path.
    if path is None and isinstance(source, str):
        if source.endswith((".yaml", ".yml")) and Path(source).is_file():
            path = Path(source)

    if path is not None:
        if not path.is_file():
            raise PolicyError(f"Policy file not found: {path}")
        try:
            text = path.read_text()
        except (PermissionError, OSError) as exc:
            raise PolicyError(f"Cannot read policy file {path}: {exc}") from exc
    else:
        text = str(source)

    try:
        raw = yaml.safe_load(text)
    except yaml.YAMLError as exc:
        raise PolicyError(f"Invalid YAML in policy: {exc}") from exc

    if raw is None:
        return {}

    if not isinstance(raw, dict):
        raise PolicyError(
            f"Policy YAML must be a mapping, got {type(raw).__name__}"
        )

    return raw


def _parse_policy(raw: dict[str, Any]) -> SandboxPolicy:
    """Build a ``SandboxPolicy`` from parsed YAML, merging with a preset."""
    preset_name = raw.get("policy", "standard")

    if not isinstance(preset_name, str):
        raise PolicyError(
            f"'policy' must be a string, got {type(preset_name).__name__}"
        )

    policy = load_preset(preset_name)

    # --- Network overrides ---
    if "network" in raw:
        net_raw = raw["network"]
        if not isinstance(net_raw, dict):
            raise PolicyError("'network' must be a mapping")
        if "enabled" in net_raw:
            policy.network.enabled = bool(net_raw["enabled"])
        if "allowed_domains" in net_raw:
            domains = net_raw["allowed_domains"]
            if not isinstance(domains, list):
                raise PolicyError("'network.allowed_domains' must be a list")
            _validate_domains(domains)
            policy.network.allowed_domains = domains

    # --- Filesystem overrides ---
    if "filesystem" in raw:
        fs_raw = raw["filesystem"]
        if not isinstance(fs_raw, dict):
            raise PolicyError("'filesystem' must be a mapping")
        if "read_only_mounts" in fs_raw:
            mounts = fs_raw["read_only_mounts"]
            if not isinstance(mounts, list):
                raise PolicyError("'filesystem.read_only_mounts' must be a list")
            policy.filesystem.read_only_mounts = [str(m) for m in mounts]
        if "writable_dirs" in fs_raw:
            dirs = fs_raw["writable_dirs"]
            if not isinstance(dirs, list):
                raise PolicyError("'filesystem.writable_dirs' must be a list")
            policy.filesystem.writable_dirs = [str(d) for d in dirs]

    # --- Resource overrides ---
    if "resources" in raw:
        res_raw = raw["resources"]
        if not isinstance(res_raw, dict):
            raise PolicyError("'resources' must be a mapping")
        if "cpu_limit" in res_raw:
            cpu = str(res_raw["cpu_limit"])
            if not _validate_cpu_limit(cpu):
                raise PolicyError(
                    f"Invalid cpu_limit: '{cpu}'. "
                    "Must be a positive decimal number (e.g. '0.5', '2.0')."
                )
            policy.resources.cpu_limit = cpu
        if "memory_limit" in res_raw:
            mem = str(res_raw["memory_limit"])
            if not _validate_memory_limit(mem):
                raise PolicyError(
                    f"Invalid memory_limit: '{mem}'. "
                    "Use Docker notation (e.g. '256m', '1g')."
                )
            policy.resources.memory_limit = mem
        if "timeout_seconds" in res_raw:
            timeout = res_raw["timeout_seconds"]
            if not isinstance(timeout, int):
                raise PolicyError("'resources.timeout_seconds' must be an integer")
            if not _validate_timeout(timeout):
                raise PolicyError(
                    f"Invalid timeout_seconds: {timeout}. "
                    "Must be between 1 and 3600."
                )
            policy.resources.timeout_seconds = timeout

    # If overrides were applied, mark as custom (unless it was already a
    # preset with no actual changes — keep preset name for clarity).
    has_overrides = any(k in raw for k in ("network", "filesystem", "resources"))
    if has_overrides:
        policy.name = "custom"

    return policy


def _validate_domains(domains: list[Any]) -> None:
    """Validate a list of domain strings.  Raises ``PolicyError`` on failure."""
    for domain in domains:
        if not isinstance(domain, str):
            raise PolicyError(
                f"Each domain must be a string, got {type(domain).__name__}: {domain}"
            )
        if not _validate_domain(domain):
            raise PolicyError(
                f"Invalid domain pattern: '{domain}'. "
                "Expected a hostname like 'api.example.com' or a wildcard like '*.example.com'."
            )
