"""kv agent — daemon that holds decrypted secrets in memory.

Like ssh-agent: unlock once, all processes use it until you stop it.
Listens on a Unix domain socket. Only serves safe operations:
  - run: execute command with secrets injected, return redacted output
  - list: return secret key names (no values)
  - status: return agent status
  - envs: return environment names

Does NOT serve:
  - get: never returns secret values through the socket
  - export: never dumps secrets

Security:
  - Socket is chmod 600 (owner only)
  - PR_SET_DUMPABLE=0 prevents ptrace / /proc/PID/mem reads
  - Commands validated against allowlist + blocklist before execution
  - Subprocesses run in network-isolated namespace when available
  - Output redacted before returning to caller
  - Post-execution file scan detects leaked secrets on disk
"""

import json
import os
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
import urllib.error
import ssl

from .sandbox import (
    check_command, wrap_argv, redact,
    scan_leaked_files, track_new_files,
    protect_process_memory,
)

PORT_DIR = os.path.join(os.path.expanduser("~"), ".kv")
MAX_MSG = 1024 * 1024  # 1MB max message size
RUN_TIMEOUT = 60  # seconds
AGENT_HOST = "127.0.0.1"


def _agent_port(project_root=None):
    """Deterministic TCP port from project root. Range: 19200-19999.

    Each project gets its own port. No files involved — both client
    and server compute the same port from the same project root.
    """
    if project_root is None:
        from .config import find_project_root
        project_root = find_project_root()
    if not project_root:
        return 19742  # fallback default
    import hashlib
    h = hashlib.sha256(project_root.encode()).hexdigest()
    return 19200 + (int(h[:8], 16) % 800)


def is_agent_running():
    """Check if a kv agent is running and reachable on TCP localhost."""
    port = _agent_port()
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(2)
        sock.connect((AGENT_HOST, port))
        sock.sendall(json.dumps({"cmd": "status"}).encode())
        data = sock.recv(4096)
        sock.close()
        return True
    except (ConnectionRefusedError, OSError, TimeoutError):
        return False


def agent_request(cmd, **kwargs):
    """Send a request to the running agent. Returns response dict."""
    port = _agent_port()
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(RUN_TIMEOUT + 5)
    sock.connect((AGENT_HOST, port))

    request = {"cmd": cmd, **kwargs}
    sock.sendall(json.dumps(request).encode())

    # Read response
    chunks = []
    while True:
        data = sock.recv(65536)
        if not data:
            break
        chunks.append(data)
    sock.close()

    return json.loads(b"".join(chunks))


API_TIMEOUT = 120  # seconds — API calls can be slow


def _providers_module():
    """Lazy import to avoid circular deps."""
    from . import providers
    return providers


def _handle_api_call(request, all_secrets, env_name):
    """Handle an 'api' command — make HTTP call with injected credentials.

    The API key never leaves this process. The agent gets the response body.
    """
    provider_name = request.get("provider")
    if not provider_name:
        return {"error": "provider is required (e.g. openai, anthropic, google)"}

    providers = _providers_module()
    provider = providers.get_provider(provider_name)
    if not provider:
        available = ", ".join(providers.list_providers())
        return {"error": f"unknown provider: {provider_name}. available: {available}"}

    # Get the secret for this provider
    secrets = all_secrets.get(env_name, {})
    secret_name = provider["secret_name"]
    secret_value = secrets.get(secret_name)
    if not secret_value:
        return {"error": f"secret '{secret_name}' not found in env '{env_name}'"}

    return providers.make_api_call(
        provider_name=provider_name,
        secret_value=secret_value,
        path=request.get("path", "/"),
        method=request.get("method", "POST"),
        body=request.get("body"),
        extra_headers=request.get("headers", {}),
        secrets_for_redact=secrets,
        timeout=API_TIMEOUT,
    )


def run_agent(store, default_env):
    """Run the kv agent daemon. Blocks until interrupted.

    Args:
        store: SecretStore with master key already loaded
        default_env: default environment name
    """
    # ── Harden process memory ──
    protect_process_memory()

    # ── Load all secrets into memory (with hot-reload support) ──
    from .config import list_environments, get_run_policy, is_network_allowed
    all_secrets = {}
    _enc_mtimes = {}  # {env_name: mtime} — track file changes

    def _load_env_secrets(env_name):
        """Load secrets for one environment and record its mtime."""
        try:
            all_secrets[env_name] = store.get_all_secrets(env_name)
        except Exception:
            all_secrets[env_name] = {}
        # Record mtime
        enc_path = store._enc_path(env_name)
        try:
            _enc_mtimes[env_name] = os.path.getmtime(enc_path)
        except OSError:
            _enc_mtimes[env_name] = 0

    def _refresh_secrets():
        """Check if any .enc files changed and reload if needed.

        Called on each request. Only re-decrypts environments whose
        .enc file mtime changed. Also picks up new environments.
        """
        changed = False
        # Check existing environments for file changes
        for env_name in list(all_secrets.keys()):
            enc_path = store._enc_path(env_name)
            try:
                current_mtime = os.path.getmtime(enc_path)
            except OSError:
                current_mtime = 0
            if current_mtime != _enc_mtimes.get(env_name, 0):
                _load_env_secrets(env_name)
                changed = True

        # Check for new environments
        try:
            current_envs = list_environments(store.root)
            for env_name in current_envs:
                if env_name not in all_secrets:
                    _load_env_secrets(env_name)
                    changed = True
        except Exception:
            pass

        return changed

    # Initial load
    envs = list_environments(store.root)
    for env in envs:
        _load_env_secrets(env)

    # ── Security policy (read fresh on each request — see _get_allowlist) ──
    def _get_allowlist():
        """Read allowlist from config on each request (hot-reload)."""
        p, cmds = get_run_policy(store.root)
        return cmds if p == "allowlist" else None

    # ── Load custom providers from config ──
    from .providers import load_custom_providers
    custom_count = load_custom_providers(store.root)

    # ── Stop previous agent ──
    os.makedirs(PORT_DIR, mode=0o700, exist_ok=True)
    pid_file = os.path.join(PORT_DIR, "agent.pid")
    port = _agent_port(store.root)

    # Kill old agent via PID file
    if os.path.isfile(pid_file):
        try:
            old_pid = int(open(pid_file).read().strip())
            if old_pid != os.getpid():
                os.kill(old_pid, signal.SIGTERM)
                import time as _t
                for _ in range(6):
                    _t.sleep(0.5)
                    try:
                        os.kill(old_pid, 0)
                    except ProcessLookupError:
                        break
        except (ValueError, ProcessLookupError, PermissionError):
            pass

    # Write our PID
    with open(pid_file, "w") as f:
        f.write(str(os.getpid()))

    # Clean up legacy socket files (migration from Unix sockets)
    for _legacy_dir in [os.path.join(PORT_DIR, "sockets"),
                        os.path.join(tempfile.gettempdir(), "kv-agent")]:
        if os.path.isdir(_legacy_dir):
            for _f in os.listdir(_legacy_dir):
                try:
                    os.unlink(os.path.join(_legacy_dir, _f))
                except OSError:
                    pass

    # Create TCP server on localhost — no files, nothing to delete
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((AGENT_HOST, port))
    server.listen(5)
    server.settimeout(1.0)

    # Handle graceful shutdown — log which signal caused it
    running = True
    _shutdown_signal = None

    def _shutdown(signum, frame):
        nonlocal running, _shutdown_signal
        sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum)
        _shutdown_signal = sig_name
        # Log immediately — before cleanup, in case cleanup hangs
        try:
            ts = time.strftime("%Y-%m-%d %H:%M:%S")
            with open(log_path, "a", encoding="utf-8") as f:
                f.write(f"[{ts}] SIGNAL received: {sig_name} (PID {pid})\n")
        except Exception:
            pass
        running = False

    signal.signal(signal.SIGINT, _shutdown)
    signal.signal(signal.SIGTERM, _shutdown)
    signal.signal(signal.SIGHUP, _shutdown)  # terminal close
    signal.signal(signal.SIGPIPE, signal.SIG_IGN)  # ignore broken pipe — prevents silent kill

    pid = os.getpid()

    # atexit: catch unexpected exits (uncaught exceptions, sys.exit)
    import atexit
    def _atexit_log():
        try:
            ts = time.strftime("%Y-%m-%d %H:%M:%S")
            with open(log_path, "a", encoding="utf-8") as f:
                if _shutdown_signal:
                    f.write(f"[{ts}] agent exited (signal={_shutdown_signal}, PID {pid})\n")
                else:
                    f.write(f"[{ts}] agent exited UNEXPECTEDLY (no signal, PID {pid})\n")
        except Exception:
            pass
    atexit.register(_atexit_log)

    # Files created during kv_run that may contain secrets.
    # The PreToolUse hook checks this list to block reads.
    tracked_files = set()
    secret_count = sum(len(v) for v in all_secrets.values())
    env_count = len(envs)

    sys.stderr.write(f"\n  kv agent running (PID {pid})\n")
    sys.stderr.write(f"  {env_count} environment{'s' if env_count != 1 else ''}, "
                     f"{secret_count} secret{'s' if secret_count != 1 else ''} loaded\n")
    sys.stderr.write(f"  port: {port}\n")
    _startup_policy, _startup_allowed = get_run_policy(store.root)
    if _startup_policy == "allowlist":
        sys.stderr.write(f"  policy: allowlist ({len(_startup_allowed)} command{'s' if len(_startup_allowed) != 1 else ''})\n")
    else:
        sys.stderr.write(f"  policy: blocklist\n")
    sys.stderr.write(f"\n  Ctrl+C to stop\n\n")

    # ── Logging ──
    log_dir = os.path.expanduser("~/.kv")
    os.makedirs(log_dir, mode=0o700, exist_ok=True)
    log_path = os.path.join(log_dir, "agent.log")

    # Check if stderr is a real terminal (foreground) or redirected (background)
    _stderr_is_tty = hasattr(sys.stderr, "isatty") and sys.stderr.isatty()

    def _log(msg):
        """Append to log file. Only write to stderr in foreground mode."""
        ts = time.strftime("%Y-%m-%d %H:%M:%S")
        line = f"[{ts}] {msg}"
        if _stderr_is_tty:
            sys.stderr.write(f"  {line}\n")
        try:
            with open(log_path, "a", encoding="utf-8") as f:
                f.write(line + "\n")
        except Exception:
            pass

    _log(f"agent started (PID {pid})")

    from .audit import emit as _audit
    _audit("agent_start", pid=pid, envs=env_count, secrets=secret_count,
           policy=_startup_policy)
    _start_time = time.time()

    while running:
        try:
            conn, _ = server.accept()
        except socket.timeout:
            continue
        except OSError:
            break

        # Handle each connection in a try/finally to ensure cleanup
        try:
            conn.settimeout(API_TIMEOUT + 10)

            # Read full message — client sends JSON + closes write side
            chunks = []
            while True:
                chunk = conn.recv(65536)
                if not chunk:
                    break
                chunks.append(chunk)
                # Optimization: try to parse — if valid JSON, stop reading
                try:
                    json.loads(b"".join(chunks))
                    break
                except json.JSONDecodeError:
                    continue
            data = b"".join(chunks)
            if not data:
                conn.close()
                continue

            request = json.loads(data)
            cmd = request.get("cmd")
            env_name = request.get("env", default_env)

            # Hot-reload: check if .enc files changed since last request
            if _refresh_secrets():
                secret_count = sum(len(v) for v in all_secrets.values())
                _log("secrets reloaded (file change detected)")
            _log(f"request: {cmd} env={env_name}")

            if cmd == "run":
                # Execute command with secrets injected
                argv = request.get("argv", [])
                if not argv:
                    response = {"error": "argv required", "exit_code": 1}
                else:
                    # Layer 1: Command validation (allowlist + blocklist)
                    safe, reason = check_command(argv, _get_allowlist())
                    if not safe:
                        response = {"error": reason, "exit_code": 1}
                        _audit("run_blocked", program=os.path.basename(argv[0]),
                               reason=reason, env=env_name)
                    else:
                        secrets = all_secrets.get(env_name, {})
                        run_env = dict(os.environ)
                        run_env.update(secrets)
                        scan_dirs = [os.getcwd(), tempfile.gettempdir()]
                        before_time = time.time()

                        # Layer 2: Network isolation
                        # Network access controlled by config, not by caller
                        needs_network = is_network_allowed(store.root, argv)
                        run_argv = wrap_argv(argv, network=needs_network)

                        try:
                            result = subprocess.run(
                                run_argv, env=run_env, shell=False,
                                capture_output=True, text=True,
                                timeout=RUN_TIMEOUT,
                            )
                            stdout = redact(result.stdout, secrets) if result.stdout else ""
                            stderr = redact(result.stderr, secrets) if result.stderr else ""

                            # Layer 3: Post-execution file scan
                            leaked = scan_leaked_files(secrets, before_time, scan_dirs)
                            leak_warning = ""
                            if leaked:
                                for lpath, name in leaked:
                                    tracked_files.add(lpath)
                                    try:
                                        os.unlink(lpath)
                                    except OSError:
                                        pass
                                leak_names = [f"{name} → {lpath}" for lpath, name in leaked]
                                leak_warning = (
                                    f"\n[SECURITY] detected and deleted {len(leaked)} "
                                    f"file(s) containing secrets: {', '.join(leak_names)}"
                                )

                            # Track new files (for hook to block reads)
                            track_new_files(before_time, scan_dirs, tracked_files)

                            duration_ms = int((time.time() - before_time) * 1000)
                            response = {
                                "exit_code": result.returncode,
                                "stdout": stdout,
                                "stderr": stderr,
                            }
                            _audit("run_exec",
                                   program=os.path.basename(argv[0]),
                                   exit_code=result.returncode,
                                   duration_ms=duration_ms, env=env_name)
                            if leaked:
                                for lpath, name in leaked:
                                    _audit("leak_file", path=lpath,
                                           secret_name=name)
                            if leak_warning:
                                response["warning"] = leak_warning

                        except subprocess.TimeoutExpired:
                            response = {"error": f"timeout ({RUN_TIMEOUT}s)", "exit_code": 1}
                        except FileNotFoundError:
                            response = {"error": f"command not found: {argv[0]}", "exit_code": 1}

            elif cmd == "list":
                # Return key names only — NEVER values
                secrets = all_secrets.get(env_name, {})
                response = {"keys": sorted(secrets.keys()), "env": env_name}

            elif cmd == "envs":
                response = {"environments": sorted(all_secrets.keys())}

            elif cmd == "status":
                _current_sec = sum(len(v) for v in all_secrets.values())
                response = {
                    "running": True,
                    "pid": pid,
                    "root": store.root,
                    "environments": len(all_secrets),
                    "secrets": _current_sec,
                    "providers": list(_providers_module().list_providers()),
                    "policy": get_run_policy(store.root)[0],
                }

            elif cmd == "api":
                # Make HTTP API call with credentials injected
                api_start = time.time()
                response = _handle_api_call(request, all_secrets, env_name)
                api_ms = int((time.time() - api_start) * 1000)
                provider_name = request.get("provider", "?")
                _audit("api_call",
                       provider=provider_name,
                       path=request.get("path", "/"),
                       method=request.get("method", "POST"),
                       status=response.get("status", 0),
                       duration_ms=api_ms,
                       env=env_name,
                       error=bool(response.get("error")))

            elif cmd == "check_file":
                # Check if a file path is tracked (created during kv_run)
                # Used by the PreToolUse hook to block reads
                file_path = request.get("path", "")
                real_path = os.path.realpath(file_path) if file_path else ""
                is_tracked = real_path in tracked_files
                response = {"tracked": is_tracked, "path": real_path}

            elif cmd == "tracked_files":
                # List all tracked files (for hook to check)
                response = {"files": sorted(tracked_files), "count": len(tracked_files)}

            elif cmd == "secret_names":
                # Return secret key names (not values) — for hook to know what to scan for
                secrets = all_secrets.get(env_name, {})
                response = {"names": sorted(secrets.keys())}

            elif cmd == "check_leak":
                # Check if text contains any secret values
                # Used by PostToolUse hook to detect leaks in Bash output
                # Returns count only — not names (avoid oracle side-channel)
                text = request.get("text", "")
                secrets = all_secrets.get(env_name, {})
                leak_count = 0
                for name, value in secrets.items():
                    if value and len(value) >= 8 and value in text:
                        leak_count += 1
                        _log(f"LEAK DETECTED: {name} found in command output")
                response = {"leaked": leak_count > 0, "count": leak_count}
                if leak_count > 0:
                    _audit("leak_detected", count=leak_count, env=env_name)

            else:
                response = {"error": f"unknown command: {cmd}"}

            resp_bytes = json.dumps(response).encode()
            try:
                conn.sendall(resp_bytes)
            except BrokenPipeError:
                pass  # Client disconnected — normal, not an error
            _log(f"response: {cmd} {'error' if 'error' in response else 'ok'}")

        except json.JSONDecodeError as e:
            _log(f"error: bad JSON from client: {e}")
            try:
                conn.sendall(json.dumps({"error": "invalid JSON"}).encode())
            except Exception:
                pass
        except BrokenPipeError:
            pass  # Client disconnected mid-request — ignore
        except Exception as e:
            _log(f"error: {type(e).__name__}: {e}")
            try:
                conn.sendall(json.dumps({"error": str(e)}).encode())
            except Exception:
                pass
        finally:
            try:
                conn.close()
            except Exception:
                pass

    # Cleanup — no socket file to delete (TCP), just PID file
    _log(f"agent stopping (signal={_shutdown_signal})")
    _audit("agent_stop", pid=pid, uptime_s=int(time.time() - _start_time),
           signal=_shutdown_signal)
    server.close()

    # Only delete PID file if we still own it
    try:
        current_pid = int(open(pid_file).read().strip())
        if current_pid == pid:
            os.unlink(pid_file)
    except Exception:
        pass

    sys.stderr.write("  agent stopped\n")
