"""Sandbox command wrapping via srt.

anima translates capability flags into srt configuration.
srt handles OS-level enforcement (macOS seatbelt, Linux bubblewrap).

Core zone: @pre/@post contracts + doctests, no I/O imports.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field

from deal import post, pre
from invar_runtime import skip_property_test


@dataclass
class SandboxPolicy:
    """Parsed sandbox capabilities from CLI/config.

    enabled: Whether sandbox is active (default False).
    allow_fs: Filesystem paths allowed for access.
    allow_net: Network domains allowed (["*"] for full network).

    >>> policy = SandboxPolicy(enabled=True, allow_fs=["/tmp"], allow_net=["*"])
    >>> policy.enabled
    True
    >>> policy.allow_fs
    ['/tmp']
    >>> policy.allow_net
    ['*']
    """

    enabled: bool = False
    allow_fs: list[str] = field(default_factory=list)
    allow_net: list[str] = field(default_factory=list)


@pre(
    lambda allows: all(
        isinstance(a, str)
        and len(a.strip()) > 0
        and (
            # fs:<path> where <path> is non-empty and non-whitespace
            (a.startswith("fs:") and len(a[3:].strip()) > 0)
            or a == "net"  # exactly net
            # net:<domain> where <domain> is non-empty and non-whitespace
            or (a.startswith("net:") and len(a[4:].strip()) > 0)
        )
        for a in allows
    )
)
@post(lambda result: isinstance(result, SandboxPolicy))
def parse_sandbox_allow(allows: list[str]) -> SandboxPolicy:
    """Parse --sandbox-allow strings into SandboxPolicy.

    @pre validates capability format: strings must be one of:
        - "fs:<path>" where <path> is non-empty and non-whitespace
        - "net" (exact match)
        - "net:<domain>" where <domain> is non-empty and non-whitespace

    Whitespace-only strings, empty strings, unknown capability prefixes,
    whitespace-only values after colon, and empty values after colon
    are all rejected by @pre.

    Empty list → enabled policy with no extra permissions.

    Edge cases:
        "fs:/tmp"              → allow_fs=["/tmp"]
        "fs:  /tmp  "          → allow_fs=["/tmp"] (whitespace stripped)
        "fs:"                  → PreContractError (empty path)
        "fs: "                 → PreContractError (whitespace-only path)
        "fs:  "                → PreContractError (whitespace-only path)
        "net"                  → allow_net=["*"]
        "net:api.brave.com"    → allow_net=["api.brave.com"]
        "net:  api.brave.com  " → allow_net=["api.brave.com"] (whitespace stripped)
        "net:"                 → PreContractError (empty domain)
        "net: "                → PreContractError (whitespace-only domain)
        "" (empty string)      → PreContractError (rejected by @pre)
        "  " (whitespace)      → PreContractError (rejected by @pre)
        "disk:/tmp"            → PreContractError (unknown capability)

    >>> parse_sandbox_allow(["fs:/tmp"])
    SandboxPolicy(enabled=True, allow_fs=['/tmp'], allow_net=[])
    >>> parse_sandbox_allow(["fs:  /tmp  "])  # whitespace stripped
    SandboxPolicy(enabled=True, allow_fs=['/tmp'], allow_net=[])
    >>> parse_sandbox_allow(["net"])
    SandboxPolicy(enabled=True, allow_fs=[], allow_net=['*'])
    >>> parse_sandbox_allow(["net:api.brave.com"])
    SandboxPolicy(enabled=True, allow_fs=[], allow_net=['api.brave.com'])
    >>> parse_sandbox_allow(["net:  api.brave.com  "])  # whitespace stripped
    SandboxPolicy(enabled=True, allow_fs=[], allow_net=['api.brave.com'])
    >>> parse_sandbox_allow([])
    SandboxPolicy(enabled=True, allow_fs=[], allow_net=[])
    >>> parse_sandbox_allow(["fs:/tmp", "net:example.com"])
    SandboxPolicy(enabled=True, allow_fs=['/tmp'], allow_net=['example.com'])
    >>> parse_sandbox_allow(["fs:"])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    >>> parse_sandbox_allow(["fs: "])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    >>> parse_sandbox_allow(["fs:  "])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    >>> parse_sandbox_allow(["net:"])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    >>> parse_sandbox_allow(["net: "])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    >>> parse_sandbox_allow(["net:  "])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    >>> parse_sandbox_allow([""])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    >>> parse_sandbox_allow(["  "])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    >>> parse_sandbox_allow(["disk:/tmp"])  # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    ...
    PreContractError: ...
    """
    if not allows:
        return SandboxPolicy(enabled=True)

    fs_paths: list[str] = []
    net_domains: list[str] = []

    for token in allows:
        if token.startswith("fs:"):
            # @pre ensures path is non-empty after stripping
            path = token[3:].strip()
            if path not in fs_paths:
                fs_paths.append(path)

        elif token == "net":
            if "*" not in net_domains:
                net_domains.append("*")

        elif token.startswith("net:"):
            # @pre ensures domain is non-empty after stripping
            domain = token[4:].strip()
            if domain not in net_domains:
                net_domains.append(domain)

    return SandboxPolicy(enabled=True, allow_fs=fs_paths, allow_net=net_domains)


@post(
    lambda result: isinstance(result, list) and all(isinstance(s, str) for s in result)
)
def merge_sandbox_allows(cli: list[str], config: list[str]) -> list[str]:
    """Union CLI and config sandbox_allow lists (additive).

    CLI --sandbox-allow and config sandbox_allow are unioned.
    No subtraction semantics.

    >>> merge_sandbox_allows(["fs:/tmp"], ["net"])
    ['fs:/tmp', 'net']
    >>> merge_sandbox_allows(["fs:/tmp"], ["fs:/var", "net"])
    ['fs:/tmp', 'fs:/var', 'net']
    >>> merge_sandbox_allows([], ["net"])
    ['net']
    >>> merge_sandbox_allows(["fs:/tmp"], [])
    ['fs:/tmp']
    >>> merge_sandbox_allows(["fs:/tmp"], ["fs:/tmp"])
    ['fs:/tmp']
    """
    # Preserve insertion order while deduplicating via seen-set
    combined = []
    seen = set()

    for token in cli + config:
        if token not in seen:
            seen.add(token)
            combined.append(token)

    return combined


@post(
    lambda result: (
        isinstance(result, dict)
        and "allow_fs" in result
        and "allow_net" in result
        and isinstance(result["allow_fs"], list)
        and isinstance(result["allow_net"], list)
    )
)
def build_srt_config(policy: SandboxPolicy) -> dict:
    """Translate SandboxPolicy → srt configuration dict.

    >>> policy = SandboxPolicy(enabled=True, allow_fs=["/tmp"], allow_net=["*"])
    >>> cfg = build_srt_config(policy)
    >>> cfg["allow_fs"]
    ['/tmp']
    >>> cfg["allow_net"]
    ['*']
    >>> cfg = build_srt_config(SandboxPolicy())
    >>> cfg["allow_fs"]
    []
    >>> cfg["allow_net"]
    []
    """
    return {
        "allow_fs": list(policy.allow_fs),
        "allow_net": list(policy.allow_net),
    }


@skip_property_test("optional dependency: srt not installed in test environment")
# @invar:allow missing_contract: Command passthrough/wrapper behavior depends on policy and cannot be expressed in result-only @post
def wrap_command(command: list[str], policy: SandboxPolicy) -> list[str]:
    """Wrap command for sandboxed execution via srt.

    When policy.enabled=False, returns command unchanged (no sandbox wrapper).
    When policy.enabled=True, returns srt command wrapper.

    This is a pure data transformation function. The caller (Shell layer) must
    verify that the sandbox-runtime package is installed before executing the
    returned command. If srt is not installed, the subprocess will fail with
    FileNotFoundError (srt CLI not found).

    Usage:
        Shell layer should check srt availability separately:
            try:
                importlib.import_module("srt")
            except ImportError:
                raise ImportError("Sandbox requires sandbox-runtime. Install with: pip install 'anima[sandbox]'")

        Then call wrap_command to build the command list.

    >>> policy_disabled = SandboxPolicy(enabled=False)
    >>> wrap_command(["python", "-V"], policy_disabled)
    ['python', '-V']

    >>> policy_enabled = SandboxPolicy(enabled=True, allow_fs=["/tmp"])
    >>> wrap_command(["python", "-V"], policy_enabled)  # doctest: +SKIP
    ['srt', 'exec', '--config', '{"allow_fs":["/tmp"],"allow_net":[]}', '--', 'python', '-V']

    The returned command list is suitable for subprocess execution:
    >>> wrap_command(["python", "-V"], SandboxPolicy(enabled=True))  # doctest: +SKIP
    ['srt', 'exec', '--config', '{"allow_fs":[],"allow_net":[]}', '--', 'python', '-V']
    """
    # Early return if sandbox is disabled
    if not policy.enabled:
        return command

    config = build_srt_config(policy)
    config_json = json.dumps(config, separators=(",", ":"))
    return ["srt", "exec", "--config", config_json, "--", *command]


@dataclass
class NetworkBlockErrorInfo:
    """Structured network block error info (no presentation markup).

    Returned by format_network_block_error to separate data from presentation.
    Shell layer is responsible for Rich markup formatting.

    >>> info = NetworkBlockErrorInfo(
    ...     error_message="Connection refused",
    ...     suggestion="Use --sandbox-allow net to allow all network access, or --sandbox-allow net:<domain> for specific domains.",
    ...     allowed_domains=[],
    ...     is_sandbox_related=True,
    ... )
    >>> info.is_sandbox_related
    True
    >>> info.allowed_domains
    []
    """

    error_message: str
    suggestion: str
    allowed_domains: list[str]
    is_sandbox_related: bool


@pre(
    lambda error_message, policy: (
        len(error_message) > 0 and isinstance(policy, SandboxPolicy)
    )
)
@post(lambda result: result is None or isinstance(result, NetworkBlockErrorInfo))
def format_network_block_error(
    error_message: str,
    policy: SandboxPolicy,
) -> NetworkBlockErrorInfo | None:
    """Detect sandbox network block errors and return structured info.

    Spec source: sandbox-srt.ux-banner

    When a network access error occurs due to sandbox restrictions, return
    structured error info. The shell layer formats this with Rich markup.

    Args:
        error_message: Error message from exception.
        policy: Active sandbox policy (for context on current allows).

    Returns:
        NetworkBlockErrorInfo with structured data, or None if not a network block.

    >>> policy = SandboxPolicy(enabled=True, allow_fs=[], allow_net=[])
    >>> result = format_network_block_error("Connection refused", policy)
    >>> result.error_message
    'Connection refused'
    >>> result.is_sandbox_related
    True
    >>> "net" in result.suggestion
    True

    >>> policy_disabled = SandboxPolicy(enabled=False)
    >>> format_network_block_error("Connection refused", policy_disabled) is None
    True

    >>> format_network_block_error("File not found", policy) is None
    True

    >>> policy_with_net = SandboxPolicy(enabled=True, allow_fs=[], allow_net=["api.example.com"])
    >>> result = format_network_block_error("ENETUNREACH", policy_with_net)
    >>> result.allowed_domains
    ['api.example.com']
    """
    # Network-related error patterns (srt/sandbox blocks manifest as various OS errors)
    network_patterns = [
        "ECONNREFUSED",
        "ENETUNREACH",
        "EHOSTUNREACH",
        "Connection refused",
        "Network is unreachable",
        "No route to host",
        "network is down",
        "network error",
        "socket:",
        "socket error",
        "connection reset",
        "connection timed out",
    ]
    error_lower = error_message.lower()
    is_network_error = any(
        pattern.lower() in error_lower for pattern in network_patterns
    )
    if not is_network_error:
        return None

    # Sandbox must be enabled for this to be a sandbox-related network block
    if not policy.enabled:
        return None

    suggestion = (
        "Use --sandbox-allow net to allow all network access, "
        "or --sandbox-allow net:<domain> for specific domains."
    )

    # Domains currently allowed (excluding wildcard)
    allowed_domains = [d for d in policy.allow_net if d != "*"]

    return NetworkBlockErrorInfo(
        error_message=error_message,
        suggestion=suggestion,
        allowed_domains=allowed_domains,
        is_sandbox_related=True,
    )


# @invar:allow missing_contract: Predicate intentionally accepts any str and returns validity as bool
def _is_valid_allow_entry(a: str) -> bool:
    """Check if a sandbox allow entry is valid for parse_sandbox_allow.

    >>> _is_valid_allow_entry("fs:/tmp")
    True
    >>> _is_valid_allow_entry("net")
    True
    >>> _is_valid_allow_entry("")
    False
    >>> _is_valid_allow_entry("disk:/tmp")
    False
    """
    if len(a.strip()) == 0:
        return False
    return (
        (a.startswith("fs:") and len(a[3:].strip()) > 0)
        or a == "net"
        or (a.startswith("net:") and len(a[4:].strip()) > 0)
    )


@pre(
    lambda sandbox, no_sandbox, sandbox_allow, preset_sandbox, preset_allows: (
        not (sandbox and no_sandbox)
        and (preset_sandbox or len(preset_allows) == 0)
        and all(_is_valid_allow_entry(e) for e in sandbox_allow)
        and all(_is_valid_allow_entry(e) for e in preset_allows)
    )
)
@post(lambda result: isinstance(result, SandboxPolicy))
def resolve_sandbox_policy(
    sandbox: bool,
    no_sandbox: bool,
    sandbox_allow: list[str],
    preset_sandbox: bool,
    preset_allows: list[str],
) -> SandboxPolicy:
    """Resolve final sandbox policy from CLI flags and preset config.

    Spec source: sandbox-srt.cli-flags, docs/INTERFACES.md A.1, A.2

    Truth table:
    - --sandbox-allow without --sandbox implies --sandbox
    - --no-sandbox overrides config sandbox=true and preset sandbox=true
    - --sandbox enables sandbox (overrides preset)
    - config tools.X.sandbox=true enables sandbox for that preset

    Precedence: --no-sandbox wins, then --sandbox/--sandbox-allow, then preset.

    Args:
        sandbox: CLI --sandbox flag.
        no_sandbox: CLI --no-sandbox flag.
        sandbox_allow: CLI --sandbox-allow values.
        preset_sandbox: Whether any tool preset has sandbox=true.
        preset_allows: Sandbox allow values from tool presets.

    Returns:
        Resolved SandboxPolicy.

    >>> resolve_sandbox_policy(False, False, [], False, [])
    SandboxPolicy(enabled=False, allow_fs=[], allow_net=[])

    >>> resolve_sandbox_policy(True, False, [], False, [])
    SandboxPolicy(enabled=True, allow_fs=[], allow_net=[])

    >>> resolve_sandbox_policy(False, True, [], True, [])
    SandboxPolicy(enabled=False, allow_fs=[], allow_net=[])

    >>> resolve_sandbox_policy(False, False, ["fs:/tmp"], False, [])
    SandboxPolicy(enabled=True, allow_fs=['/tmp'], allow_net=[])

    >>> resolve_sandbox_policy(False, False, [], True, ["net:example.com"])
    SandboxPolicy(enabled=True, allow_fs=[], allow_net=['example.com'])

    >>> resolve_sandbox_policy(True, False, ["fs:/tmp"], True, ["net:example.com"])
    SandboxPolicy(enabled=True, allow_fs=['/tmp'], allow_net=['example.com'])

    >>> resolve_sandbox_policy(False, True, [], False, [])
    SandboxPolicy(enabled=False, allow_fs=[], allow_net=[])
    """
    # Spec source: docs/INTERFACES.md A.1, A.2 - State Truth Table
    implicit_sandbox_from_allow = len(sandbox_allow) > 0
    sandbox_enabled = sandbox or implicit_sandbox_from_allow or preset_sandbox
    if no_sandbox:
        sandbox_enabled = False

    if not sandbox_enabled:
        return SandboxPolicy(enabled=False)

    # Merge CLI and preset sandbox_allow lists (additive)
    merged_allows = merge_sandbox_allows(
        list(sandbox_allow),
        list(set(preset_allows)),
    )

    if merged_allows:
        return parse_sandbox_allow(merged_allows)

    # Sandbox enabled but no explicit allows - use default policy
    return SandboxPolicy(enabled=True)


__all__ = [
    "SandboxPolicy",
    "NetworkBlockErrorInfo",
    "parse_sandbox_allow",
    "merge_sandbox_allows",
    "build_srt_config",
    "wrap_command",
    "format_network_block_error",
    "resolve_sandbox_policy",
]
