"""Security sandbox for kv subprocess execution.

Command validation, network isolation, output redaction, and file scanning.
Used by both the agent daemon and the MCP server direct mode.
"""

import base64
import os
import subprocess
import tempfile


# ── Command validation ──────────────────────────────────

# Shells — must never be invoked via kv_run
_BLOCKED_SHELLS = frozenset({
    "bash", "sh", "zsh", "dash", "csh", "tcsh", "fish", "ksh",
    "ash", "busybox",
})

# Programs with inline code execution flags
_INLINE_CODE_FLAGS = {
    "python": "-c", "python3": "-c", "python2": "-c",
    "node": "-e", "ruby": "-e", "perl": "-e",
    "lua": "-e", "php": "-r",
}

# Network-capable tools — could exfiltrate secrets over the wire
_NETWORK_TOOLS = frozenset({
    "curl", "wget", "fetch",
    "nc", "ncat", "netcat", "socat",
    "ssh", "scp", "sftp", "rsync",
    "telnet", "ftp", "lftp",
    "httpie", "http", "xh",
    "aria2c",
    "lwp-request",
})

# Exfiltration patterns in command strings
_EXFIL_PATTERNS = [
    "printenv >", "printenv>>", "env >", "env>>",
    "export >", "export>>", "echo $", "echo ${",
    "> /tmp/", ">> /tmp/", "tee /tmp/", "tee -a /tmp/",
    "> /var/tmp/", ">> /var/tmp/",
    ">/tmp/", ">>/tmp/",
    "| base64", "| xxd", "| od ",
]


def check_command(argv, allowed_commands=None):
    """Validate a command before execution with secrets.

    Checks (in order):
    1. Allowlist — if configured, command must match a pre-approved entry
    2. Blocklist — shells, inline code, network tools, exfil patterns

    Args:
        argv: command as list of strings
        allowed_commands: list of allowed command prefixes, or None for blocklist-only

    Returns (safe, reason).
    """
    if not argv:
        return True, ""

    program = os.path.basename(argv[0]).lower()

    # ── Allowlist (strictest — if configured) ──
    if allowed_commands is not None and allowed_commands != ["*"]:
        if not _matches_allowlist(argv, allowed_commands):
            hint = argv[0]
            if len(argv) > 1:
                hint += f" {argv[1]}"
            return False, (
                f"blocked: command not in allowlist. "
                f"Add it with: kv allow \"{hint}\""
            )
        # Allowlisted commands still get checked for shells/inline code
        # (in case the allowlist entry is too broad)

    # ── Block shells ──
    if program in _BLOCKED_SHELLS:
        return False, (
            f"blocked: shell '{program}' not allowed in kv_run. "
            f"Run a script file instead (e.g., python3 app.py)"
        )

    # ── Block inline code execution ──
    if program in _INLINE_CODE_FLAGS:
        flag = _INLINE_CODE_FLAGS[program]
        if flag in argv[1:]:
            return False, (
                f"blocked: inline code execution ('{program} {flag}') not allowed. "
                f"Write your code to a file and run that instead"
            )

    # ── Block network tools ──
    if program in _NETWORK_TOOLS:
        return False, (
            f"blocked: network tool '{program}' cannot be used with secrets. "
            f"Use kv_api for authenticated HTTP calls instead."
        )

    # ── Pattern matching ──
    cmd_str = " ".join(argv).lower()
    for pattern in _EXFIL_PATTERNS:
        if pattern.lower() in cmd_str:
            return False, (
                f"blocked: command matches exfiltration pattern '{pattern.strip()}'"
            )

    return True, ""


def _matches_allowlist(argv, allowed_commands):
    """Check if argv matches any entry in the allowlist.

    Rules:
    - "python3 app.py" matches ["python3", "app.py", "--flag"] (prefix)
    - Program names are compared by basename (/usr/bin/python3 == python3)
    - Extra arguments after the allowlist prefix are permitted
    """
    for allowed in allowed_commands:
        parts = allowed.split()
        if not parts:
            continue

        # Compare program name by basename
        if os.path.basename(argv[0]).lower() != os.path.basename(parts[0]).lower():
            if argv[0].lower() != parts[0].lower():
                continue

        # Single-word entry: program name matches any args
        if len(parts) == 1:
            return True

        # Multi-word: remaining tokens must match as prefix
        if len(argv) >= len(parts):
            if all(argv[i] == parts[i] for i in range(1, len(parts))):
                return True

    return False


# ── Network isolation ────────────────────────────────────

_isolation_cache = None


def _probe_isolation():
    """Find the best available network isolation method. Cached."""
    global _isolation_cache
    if _isolation_cache is not None:
        return _isolation_cache

    # Try unshare (lightest)
    try:
        r = subprocess.run(
            ["unshare", "--user", "--net", "true"],
            capture_output=True, timeout=5,
        )
        if r.returncode == 0:
            _isolation_cache = ["unshare", "--user", "--net"]
            return _isolation_cache
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        pass

    # Try bwrap (bubblewrap) — network isolation only, full filesystem access
    try:
        r = subprocess.run(
            ["bwrap", "--unshare-net", "--dev-bind", "/", "/", "true"],
            capture_output=True, timeout=5,
        )
        if r.returncode == 0:
            _isolation_cache = ["bwrap", "--unshare-net", "--dev-bind", "/", "/"]
            return _isolation_cache
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        pass

    _isolation_cache = []
    return _isolation_cache


def wrap_argv(argv, network=False):
    """Prepend network isolation wrapper to argv if available.

    Args:
        argv: command to wrap
        network: if True, skip network isolation (script needs internet)
    """
    if network:
        return list(argv)  # no isolation — script needs network access
    prefix = _probe_isolation()
    if prefix:
        return list(prefix) + list(argv)
    return list(argv)


# ── Output redaction ─────────────────────────────────────

def redact(text, secrets):
    """Replace secret values in text with [REDACTED].

    Sorts by length (longest first) so a secret that is a substring of
    another secret gets redacted correctly. Without this, "sk-abc123" could
    partially redact "sk-abc123-extended" → "[REDACTED]-extended".
    """
    values = [v for v in secrets.values() if v and len(v) >= 4]
    for value in sorted(values, key=len, reverse=True):
        text = text.replace(value, "[REDACTED]")
    return text


# ── Secret variant building (for file scanning) ─────────

def build_secret_variants(secrets):
    """Build plaintext + encoded variants for leak scanning."""
    variants = {}
    for name, value in secrets.items():
        if not value or len(value) < 8:
            continue
        # Plaintext
        variants[value] = name
        # Base64
        try:
            b64 = base64.b64encode(value.encode()).decode()
            if len(b64) >= 12:
                variants[b64] = name
        except Exception:
            pass
        # URL-safe base64
        try:
            b64url = base64.urlsafe_b64encode(value.encode()).decode()
            if len(b64url) >= 12:
                variants[b64url] = name
        except Exception:
            pass
        # Hex
        try:
            hexval = value.encode().hex()
            if len(hexval) >= 16:
                variants[hexval] = name
        except Exception:
            pass
        # Reversed
        rev = value[::-1]
        if len(rev) >= 8:
            variants[rev] = name
    return variants


# ── File scanning ────────────────────────────────────────

def scan_leaked_files(secrets, before_time, scan_dirs=None, max_depth=3):
    """Scan for files created/modified during subprocess execution
    that contain secret values (plaintext or encoded).

    Returns list of (path, secret_name) tuples.
    """
    if scan_dirs is None:
        scan_dirs = [os.getcwd(), tempfile.gettempdir()]

    variants = build_secret_variants(secrets)
    if not variants:
        return []

    leaked = []
    seen = set()

    def _scan(dirpath, depth):
        if depth > max_depth:
            return
        real = os.path.realpath(dirpath)
        if real in seen:
            return
        seen.add(real)

        try:
            for entry in os.scandir(dirpath):
                try:
                    if entry.is_dir(follow_symlinks=False):
                        _scan(entry.path, depth + 1)
                    elif entry.is_file(follow_symlinks=False):
                        stat = entry.stat()
                        if stat.st_mtime < before_time:
                            continue
                        if stat.st_size > 10 * 1024 * 1024:
                            continue
                        with open(entry.path, "r", errors="ignore") as f:
                            content = f.read(1024 * 1024)
                        for variant, name in variants.items():
                            if variant in content:
                                leaked.append((entry.path, name))
                                break
                except (PermissionError, OSError):
                    continue
        except (PermissionError, OSError):
            pass

    for d in scan_dirs:
        if os.path.isdir(d):
            _scan(d, 0)
    return leaked


def track_new_files(before_time, scan_dirs, tracked_set, max_depth=3):
    """Track files created/modified during kv_run for hook blocking."""
    seen = set()

    def _walk(dirpath, depth):
        if depth > max_depth:
            return
        real = os.path.realpath(dirpath)
        if real in seen:
            return
        seen.add(real)
        try:
            for entry in os.scandir(dirpath):
                try:
                    if entry.is_dir(follow_symlinks=False):
                        _walk(entry.path, depth + 1)
                    elif entry.is_file(follow_symlinks=False):
                        if entry.stat().st_mtime >= before_time:
                            tracked_set.add(os.path.realpath(entry.path))
                except (PermissionError, OSError):
                    continue
        except (PermissionError, OSError):
            pass

    for d in scan_dirs:
        if os.path.isdir(d):
            _walk(d, 0)


# ── Process memory protection ────────────────────────────

def protect_process_memory():
    """Prevent ptrace and /proc/PID/mem reads on this process.

    Sets PR_SET_DUMPABLE=0 (Linux). Same protection used by
    gpg-agent and ssh-agent to guard in-memory secrets.

    Silently no-ops on non-Linux platforms.
    """
    try:
        import ctypes
        import ctypes.util
        libc_name = ctypes.util.find_library("c")
        if libc_name:
            libc = ctypes.CDLL(libc_name, use_errno=True)
            PR_SET_DUMPABLE = 4
            libc.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0)
    except (OSError, AttributeError):
        pass
