# tools/searxng_docker.py - SearXNG in Docker/Colima (macOS only).
# Uses direct binary downloads (no Homebrew, no CLTs).
# Allow-listed commands only; no shell, no user/agent input. Not gated by allow_shell_execute.
from __future__ import annotations

import os
import subprocess
import shutil

import sys

from persistence import MUGI_DIR, MUGI_HOMEBREW_PREFIX
from tools.mugi_brew_bootstrap import get_mugi_homebrew_bin_dir, get_mugi_brew_bin, _run_brew, ensure_mugi_homebrew_core
from tools.docker_binaries import (
    MUGI_BIN, ensure_all_binaries, ensure_docker_cli,
    docker_cli_path, colima_path, limactl_path,
)

# Fixed names per plan (section 2a)
SEARXNG_CONTAINER_NAME = "searxng-mugi"
SEARXNG_IMAGE = "searxng/searxng"
SEARXNG_CONFIG_DIR = os.path.join(MUGI_DIR, "searxng", "config")

# Allowed directories for binary path hardening (optional).
# Include full Mugi Homebrew prefix so symlinks from bin/ to Cellar/... are accepted.
_MUGI_HOMEBREW_REAL = os.path.realpath(os.path.expanduser(MUGI_HOMEBREW_PREFIX)) if MUGI_HOMEBREW_PREFIX else ""
_MUGI_BIN_REAL = os.path.realpath(MUGI_BIN)
_ALLOWED_BIN_DIRS = (
    MUGI_BIN,
    os.path.expanduser("~/.mugi/homebrew/bin"),
    "/usr/local/bin",
    "/opt/homebrew/bin",
)

PORT_MIN, PORT_MAX = 1024, 65535


def _validate_port(port: int) -> bool:
    return isinstance(port, int) and PORT_MIN <= port <= PORT_MAX


def _allowed_binary_path(resolved: str) -> bool:
    """Optional hardening: reject if binary is outside known dirs. Accepts Mugi bin and Homebrew prefix (including Cellar symlinks)."""
    real = os.path.realpath(resolved)
    if _MUGI_BIN_REAL and real.startswith(_MUGI_BIN_REAL + os.sep):
        return True
    if _MUGI_HOMEBREW_REAL and real.startswith(_MUGI_HOMEBREW_REAL + os.sep):
        return True
    for d in _ALLOWED_BIN_DIRS:
        if d and real.startswith(os.path.realpath(d) + os.sep):
            return True
    return False


def get_docker_path() -> str | None:
    """Return path to docker binary (~/.mugi/bin first, then Mugi Homebrew, then system)."""
    # Direct download location (preferred)
    direct = docker_cli_path()
    if direct:
        return direct
    # Legacy Mugi Homebrew fallback
    mugi_bin = get_mugi_homebrew_bin_dir()
    docker_in_mugi = os.path.join(mugi_bin, "docker")
    if os.path.isfile(docker_in_mugi) and _allowed_binary_path(docker_in_mugi):
        return docker_in_mugi
    env_path = mugi_bin + os.pathsep + os.environ.get("PATH", "")
    found = shutil.which("docker", path=env_path)
    if found and _allowed_binary_path(found):
        return found
    return None


def get_colima_path() -> str | None:
    """Return path to colima binary (~/.mugi/bin first, then Mugi Homebrew, then system)."""
    # Direct download location (preferred)
    direct = colima_path()
    if direct:
        return direct
    # Legacy Mugi Homebrew fallback
    mugi_bin = get_mugi_homebrew_bin_dir()
    colima_in_mugi = os.path.join(mugi_bin, "colima")
    if os.path.isfile(colima_in_mugi) and _allowed_binary_path(colima_in_mugi):
        return colima_in_mugi
    env_path = mugi_bin + os.pathsep + os.environ.get("PATH", "")
    found = shutil.which("colima", path=env_path)
    if found and _allowed_binary_path(found):
        return found
    return None


# Host Homebrew bin dirs so colima/limactl can find qemu-img when app is launched from Finder (no /opt/homebrew in PATH).
_HOST_HOMEBREW_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin")
_COLIMA_DOCKER_SOCKET = os.path.expanduser("~/.colima/default/docker.sock")


def _env_with_mugi_path():
    env = os.environ.copy()
    existing_path = env.get("PATH", "")
    mugi_brew_bin = get_mugi_homebrew_bin_dir()
    # ~/.mugi/bin (direct downloads) first, then Mugi Homebrew, then host Homebrew dirs
    path_parts = [MUGI_BIN, mugi_brew_bin]
    for d in _HOST_HOMEBREW_BIN_DIRS:
        if d and d not in existing_path and os.path.isdir(d):
            path_parts.append(d)
    env["PATH"] = os.pathsep.join(path_parts) + os.pathsep + existing_path
    return env


def _wait_for_colima_socket(
    *,
    timeout_seconds: float = 60,
    poll_seconds: float = 1.0,
) -> tuple[bool, str]:
    """Wait until Colima publishes its Docker socket (common race right after colima start)."""
    import time

    deadline = time.time() + timeout_seconds
    while time.time() < deadline:
        if os.path.exists(_COLIMA_DOCKER_SOCKET):
            return True, "Colima Docker socket is ready."
        time.sleep(poll_seconds)
    return False, (
        "Colima started but its Docker socket was not ready in time. "
        "Wait a moment and retry local search setup in Settings → Network."
    )


def _docker_env_for_colima(docker_bin: str):
    """Build docker env pinned to Colima daemon/socket when available."""
    env = _env_with_mugi_path()
    # Never inherit Docker Desktop / system daemon settings from the parent process.
    env.pop("DOCKER_HOST", None)
    env.pop("DOCKER_CONTEXT", None)
    env.pop("DOCKER_TLS_VERIFY", None)
    env.pop("DOCKER_CERT_PATH", None)
    # Colima exposes a Unix socket; os.path.isfile() is false for sockets.
    if os.path.exists(_COLIMA_DOCKER_SOCKET):
        env["DOCKER_HOST"] = f"unix://{_COLIMA_DOCKER_SOCKET}"
        return env
    # Prefer Colima context if present (socket may still be materializing).
    try:
        check = subprocess.run(
            [docker_bin, "context", "inspect", "colima"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=10,
            env=env,
            shell=False,
        )
        if check.returncode == 0:
            env["DOCKER_CONTEXT"] = "colima"
    except Exception:
        pass
    return env


def ensure_docker_access_via_colima(
    *,
    max_attempts: int = 10,
    poll_seconds: float = 2.0,
) -> tuple[bool, str]:
    """Ensure docker CLI talks to Colima daemon instead of host docker socket."""
    docker_bin = get_docker_path()
    if not docker_bin:
        return False, "Docker not found."
    last_msg = "Docker daemon unavailable."
    for attempt in range(1, max_attempts + 1):
        env = _docker_env_for_colima(docker_bin)
        try:
            probe = subprocess.run(
                [docker_bin, "info"],
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=20,
                env=env,
                shell=False,
            )
        except subprocess.TimeoutExpired:
            last_msg = "Docker daemon probe timed out."
        except Exception as exc:
            last_msg = str(exc)
        else:
            if probe.returncode == 0:
                return True, "Docker is connected to Colima."
            last_msg = (probe.stderr or probe.stdout or "Docker daemon unavailable.").strip()
        if attempt < max_attempts:
            try:
                import time as _time
                _time.sleep(poll_seconds)
            except Exception:
                break
    return False, last_msg


def _run_docker(args: list[str], timeout: int = 120) -> tuple[bool, str]:
    """Run docker with fixed argv. No shell. Returns (success, output_or_error)."""
    docker_bin = get_docker_path()
    if not docker_bin:
        return False, "Docker not found. Install Colima via 'Set up SearXNG (Docker)' in Settings."

    def _run_once(bin_path: str) -> tuple[bool, str]:
        cmd = [bin_path] + args
        env = _docker_env_for_colima(bin_path)
        r = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=timeout,
            env=env,
            shell=False,
        )
        out = (r.stdout or "").strip()
        err = (r.stderr or "").strip()
        if r.returncode != 0:
            return False, err or out or f"Exit code {r.returncode}"
        # Empty stdout is normal for many docker commands; do not substitute a sentinel string
        # (callers parse line-based output and "OK" is not a container id).
        return True, out

    def _is_client_api_too_old(msg: str) -> bool:
        lower = (msg or "").lower()
        return (
            "client version" in lower
            and "too old" in lower
            and "minimum supported api version" in lower
        )

    try:
        ok, msg = _run_once(docker_bin)
        if not ok and _is_client_api_too_old(msg):
            # Self-heal older bundled Docker CLIs (e.g. API 1.43) by reinstalling.
            up_ok, up_msg = ensure_docker_cli(force_reinstall=True)
            if up_ok:
                refreshed = docker_cli_path() or docker_bin
                retry_ok, retry_msg = _run_once(refreshed)
                if retry_ok:
                    return True, retry_msg
                msg = retry_msg
            return False, (
                msg.rstrip()
                + "\n\nMugi detected a Docker client/daemon API mismatch and attempted to refresh the bundled Docker CLI. "
                + f"If this persists, rerun setup or install a newer Docker client. ({up_msg})"
            )
        return ok, msg
    except subprocess.TimeoutExpired:
        return False, "Docker command timed out."
    except Exception as e:
        return False, str(e)


def _run_colima(args: list[str], timeout: int = 120) -> tuple[bool, str]:
    """Run colima with fixed argv. No shell. Returns (success, output_or_error)."""
    colima_bin = get_colima_path()
    if not colima_bin:
        return False, "Colima not found. Run 'Set up SearXNG (Docker)' in Settings."
    cmd = [colima_bin] + args
    try:
        r = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=timeout,
            env=_env_with_mugi_path(),
            shell=False,
        )
        out = (r.stdout or "").strip()
        err = (r.stderr or "").strip()
        if r.returncode != 0:
            if out and ("No space left" in out or (len(out) < 400 and "error" in out.lower())):
                msg = out + ("\n" + err if err else "")
            else:
                msg = err or out or f"Exit code {r.returncode}"
            msg = _sanitize_colima_error(msg)
            # Surface Lima hostagent log when failure is generic
            if "error starting vm" in msg.lower() or "exit status" in msg.lower():
                try:
                    _ha = os.path.expanduser("~/.colima/_lima/colima/ha.stderr.log")
                    if os.path.isfile(_ha):
                        with open(_ha, "r", encoding="utf-8", errors="replace") as _f:
                            _lines = _f.readlines()
                        if _lines:
                            _tail = "".join(_lines[-12:]).strip()
                            if _tail and _tail not in msg:
                                msg = msg.rstrip() + "\n\nLima log (last lines):\n" + _tail
                except Exception:
                    pass
            return False, msg
        return True, out
    except subprocess.TimeoutExpired:
        return False, "Colima command timed out."
    except Exception as e:
        return False, str(e)


def _sanitize_colima_error(raw: str) -> str:
    """Extract meaningful error lines from verbose colima/lima trace output."""
    lines = raw.splitlines()
    meaningful = []
    for line in lines:
        lower = line.lower()
        if 'level=trace' in lower and 'msg="cmd' in lower:
            continue
        if 'level=trace' in lower and not ('error' in lower or 'fatal' in lower or 'fail' in lower):
            continue
        cleaned = line.strip()
        if not cleaned:
            continue
        # Strip timestamp prefix from structured log lines
        if 'level=' in cleaned and 'msg=' in cleaned:
            import re
            m = re.search(r'msg="(.+)"', cleaned)
            if m:
                cleaned = m.group(1)
        meaningful.append(cleaned)
    if not meaningful:
        return raw
    return "\n".join(meaningful[-15:])


def command_line_tools_installed() -> bool:
    """Return True when Apple Command Line Tools are available."""
    try:
        r = subprocess.run(
            ["/usr/bin/xcrun", "--find", "clang"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=10,
            shell=False,
        )
        return r.returncode == 0 and bool((r.stdout or "").strip())
    except Exception:
        return False


def trigger_command_line_tools_install() -> tuple[bool, str]:
    """Launch Apple's CLT installer prompt (xcode-select --install)."""
    try:
        r = subprocess.run(
            ["/usr/bin/xcode-select", "--install"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=20,
            shell=False,
        )
        out = ((r.stdout or "") + "\n" + (r.stderr or "")).strip()
        lower = out.lower()
        if r.returncode == 0:
            return True, "Apple Command Line Tools installer opened. Approve the macOS prompt to continue."
        if "already installed" in lower:
            return True, "Apple Command Line Tools are already installed."
        if "already requested" in lower or "install requested" in lower:
            return True, "Apple Command Line Tools installation is already in progress."
        return False, out or "Could not start Apple Command Line Tools installer."
    except Exception as e:
        return False, str(e)


def wait_for_command_line_tools_install(timeout_seconds: int = 3600, poll_seconds: int = 5) -> tuple[bool, str]:
    """Wait until Apple Command Line Tools are installed."""
    waited = 0
    while waited <= timeout_seconds:
        if command_line_tools_installed():
            return True, "Apple Command Line Tools are installed."
        try:
            import time as _time
            _time.sleep(max(1, poll_seconds))
        except Exception:
            return False, "Interrupted while waiting for Apple Command Line Tools installation."
        waited += max(1, poll_seconds)
    return False, "Timed out waiting for Apple Command Line Tools installation."


def ensure_colima_running() -> tuple[bool, str]:
    """Start Colima if not already running. Uses Virtualization.framework (--vm-type vz)."""
    # Apple Silicon only product: VZ is faster and avoids a QEMU dependency.
    vz_args = ["--vm-type", "vz", "--vz-rosetta"]
    ok, msg = _run_colima(["start", "--very-verbose", "--template=false"] + vz_args, timeout=180)
    if not ok and ("error starting vm" in msg or "exit status 1" in msg or "Exiting:true" in msg):
        _run_colima(["delete", "--force"], timeout=120)
        ok, msg = _run_colima(["start", "--very-verbose", "--template=false"] + vz_args, timeout=180)
    # Fallback: try without VZ flags (uses default VM type)
    if not ok and vz_args:
        _run_colima(["delete", "--force"], timeout=120)
        ok, msg = _run_colima(["start", "--very-verbose", "--template=false"], timeout=180)
    if not ok and "No space left" in msg:
        msg = msg.rstrip() + "\n\nFree disk space (e.g. clear ~/Library/Caches/colima or other caches) and try again."
    if not ok:
        msg = (
            msg.rstrip()
            + "\n\nIf macOS is running inside a VM: Colima needs to start a nested VM, which most hypervisors don't support. Use the app on bare metal, or configure a remote Docker host and point Mugi features that use local Docker (e.g. SearXNG, Neko) at it."
        )
    return ok, msg


def _ensure_searxng_config() -> None:
    """Create ~/.mugi/searxng/config/settings.yml with JSON format enabled and a secret key."""
    import secrets as _sec
    os.makedirs(SEARXNG_CONFIG_DIR, exist_ok=True)
    settings_path = os.path.join(SEARXNG_CONFIG_DIR, "settings.yml")
    # Reuse existing secret key if the file already has one
    existing_key = ""
    try:
        with open(settings_path, "r", encoding="utf-8") as f:
            for line in f:
                if line.strip().startswith("secret_key:"):
                    existing_key = line.strip().split(":", 1)[1].strip().strip('"').strip("'")
                    break
    except FileNotFoundError:
        pass
    secret = existing_key or _sec.token_hex(32)
    content = f"""use_default_settings: true

server:
  secret_key: "{secret}"

search:
  formats:
    - html
    - json
"""
    try:
        with open(settings_path, "w", encoding="utf-8") as f:
            f.write(content)
    except Exception:
        pass


def ensure_searxng_image_pulled() -> tuple[bool, str]:
    """Pull the fixed SearXNG image. Allow-listed only."""
    return _run_docker(["pull", SEARXNG_IMAGE], timeout=300)


def searxng_container_status() -> str:
    """Return 'running', 'stopped', or 'not_found'. Uses inspect for exact container and actual state (docker ps -f name= is substring match and can misreport)."""
    ok, out = _run_docker(
        ["inspect", "-f", "{{.State.Running}}", SEARXNG_CONTAINER_NAME],
        timeout=10,
    )
    if not ok:
        return "not_found"
    running = (out or "").strip().lower()
    if running == "true":
        return "running"
    if running == "false":
        return "stopped"
    return "not_found"


def ensure_searxng_container_running(port: int = 8888) -> tuple[bool, str]:
    """Create and start container if needed; use validated port. Recreate if stopped (so port can change)."""
    if not _validate_port(port):
        return False, f"Port must be an integer between {PORT_MIN} and {PORT_MAX}."
    status = searxng_container_status()
    if status == "running":
        return True, "SearXNG container already running."
    # Stopped or not_found: remove if exists (so we recreate with current port), then run
    _run_docker(["rm", "-f", SEARXNG_CONTAINER_NAME], timeout=15)
    _ensure_searxng_config()
    settings_file = os.path.abspath(os.path.join(SEARXNG_CONFIG_DIR, "settings.yml"))
    ok, msg = _run_docker(
        [
            "run",
            "-d",
            "-p", f"127.0.0.1:{port}:8080",
            "-v", f"{settings_file}:/etc/searxng/settings.yml",
            "--name", SEARXNG_CONTAINER_NAME,
            "--restart", "unless-stopped",
            SEARXNG_IMAGE,
        ],
        timeout=60,
    )
    return ok, msg


def searxng_start(port: int = 8888) -> tuple[bool, str]:
    """Start the container. If it was deleted (e.g. after Colima reset), recreate it."""
    ok, msg = _run_docker(["start", SEARXNG_CONTAINER_NAME])
    if not ok and "No such container" in msg:
        return ensure_searxng_container_running(port)
    return ok, msg


def searxng_stop() -> tuple[bool, str]:
    """Stop the fixed container."""
    return _run_docker(["stop", SEARXNG_CONTAINER_NAME])


def searxng_restart(port: int = 8888) -> tuple[bool, str]:
    """Restart the container. If it was deleted, recreate it."""
    ok, msg = _run_docker(["restart", SEARXNG_CONTAINER_NAME])
    if not ok and "No such container" in msg:
        return ensure_searxng_container_running(port)
    return ok, msg


def remove_searxng_setup() -> tuple[bool, str]:
    """Teardown only: docker stop/rm, colima stop/delete. Allow-listed; no shell gate."""
    errors = []
    ok, msg = _run_docker(["stop", SEARXNG_CONTAINER_NAME], timeout=30)
    if not ok and "No such container" not in msg:
        errors.append(f"docker stop: {msg}")
    ok, msg = _run_docker(["rm", "-f", SEARXNG_CONTAINER_NAME], timeout=15)
    if not ok and "No such container" not in msg:
        errors.append(f"docker rm: {msg}")
    _run_colima(["stop"], timeout=60)
    ok, msg = _run_colima(["delete", "--force"], timeout=120)
    if not ok:
        errors.append(f"colima delete: {msg}")
    if errors:
        return False, "; ".join(errors)
    return True, "SearXNG and Colima removed. You can set up again from Settings."


def _verify_searxng_http(port: int, *, max_attempts: int = 15, poll_seconds: float = 2.0) -> tuple[bool, str]:
    """Probe local SearXNG HTTP after container start (JSON search endpoint)."""
    import json
    import time
    import urllib.error
    import urllib.request

    url = f"http://127.0.0.1:{port}/search?q=mugi&format=json"
    last_err = "SearXNG did not respond."
    for attempt in range(1, max_attempts + 1):
        try:
            with urllib.request.urlopen(url, timeout=8) as resp:
                body = resp.read().decode("utf-8", errors="replace")
            data = json.loads(body)
            if isinstance(data, dict) and "results" in data:
                return True, "SearXNG health check passed."
            last_err = "SearXNG returned an unexpected response."
        except urllib.error.HTTPError as exc:
            last_err = f"SearXNG HTTP {exc.code}: {exc.reason}"
        except Exception as exc:
            last_err = str(exc) or last_err
        if attempt < max_attempts:
            time.sleep(poll_seconds)
    return False, f"SearXNG health check failed: {last_err}"


def run_setup(port: int = 8888) -> tuple[bool, str]:
    """
    One-click setup: download Docker CLI, Colima, and Lima as pre-built binaries,
    start Colima with Virtualization.framework (no QEMU), create SearXNG config
    with JSON format enabled, docker pull, docker run with config mounted.
    No Homebrew or Xcode CLTs required. Apple Silicon only.
    """
    from tools.docker_binaries import require_apple_silicon

    ok, msg = require_apple_silicon()
    if not ok:
        return False, msg
    if not _validate_port(port):
        return False, f"Port must be between {PORT_MIN} and {PORT_MAX}."
    ok, msg = ensure_all_binaries()
    if not ok:
        return False, msg
    ok, msg = ensure_colima_running()
    if not ok:
        return False, f"colima start failed: {msg}"
    ok, msg = _wait_for_colima_socket()
    if not ok:
        return False, msg
    ok, msg = ensure_docker_access_via_colima()
    if not ok:
        return False, f"docker daemon access failed: {msg}"
    _ensure_searxng_config()
    ok, msg = ensure_searxng_image_pulled()
    if not ok:
        return False, f"docker pull failed: {msg}"
    _run_docker(["rm", "-f", SEARXNG_CONTAINER_NAME], timeout=15)
    ok, msg = ensure_searxng_container_running(port)
    if not ok:
        return False, f"docker run failed: {msg}"
    ok, msg = _verify_searxng_http(port)
    if not ok:
        return False, msg
    return True, f"SearXNG is running at http://127.0.0.1:{port}"
