"""Gortex Docker Environment for SWE-bench Evaluation.

Manages the full container lifecycle for a single eval instance:
  1. Launch container with target repo at specified commit
  2. Copy gortex binary and tool bridge scripts (native/native_augment modes)
  3. Start eval-server inside container, health-check with configurable timeout
  4. Extract patch (git diff) before teardown
  5. Mount/copy cached indexes when available
  6. Record setup failures gracefully — never raise, return failure result

Architecture:
  Agent bash cmd → /usr/local/bin/gortex-search → curl localhost:4747/tool/search_symbols
    → eval-server → in-memory graph
  Fallback: → gortex CLI (cold path)
"""

from __future__ import annotations

import io
import logging
import tarfile
import time
from pathlib import Path
from typing import Any

import docker

logger = logging.getLogger("gortex_docker")

DEFAULT_EVAL_SERVER_PORT = 4747
DEFAULT_GORTEX_TIMEOUT = 120
DEFAULT_CACHE_DIR = Path.home() / ".gortex-eval-cache"
HEALTH_CHECK_INTERVAL = 2.0
CONTAINER_WORKDIR = "/testbed"
GORTEX_BINARY_CONTAINER_PATH = "/usr/local/bin/gortex"
BRIDGE_SCRIPTS_CONTAINER_DIR = "/usr/local/bin"

# Bridge script names (files in eval/bridge/ without __init__.py and __pycache__)
_BRIDGE_SCRIPTS = [
    "gortex-search",
    "gortex-context",
    "gortex-impact",
    "gortex-overview",
    "gortex-usages",
    "gortex-augment",
]


def _make_cache_key(repo_name: str, commit_hash: str) -> str:
    """Build a deterministic cache directory name from repo and commit."""
    safe_repo = repo_name.replace("/", "__")
    return f"{safe_repo}_{commit_hash}"


class GortexDockerEnvironment:
    """Docker environment managing the full container lifecycle for Gortex eval.

    Lifecycle: setup() → (agent runs) → extract_patch() → teardown()

    On any setup failure (container launch, binary copy, health-check timeout),
    the environment records the failure and returns a result dict with
    ``exit_status="setup_failure"`` instead of raising.
    """

    def __init__(
        self,
        *,
        image: str,
        repo_path: str = CONTAINER_WORKDIR,
        enable_gortex: bool = True,
        gortex_binary: str | Path | None = None,
        gortex_timeout: int = DEFAULT_GORTEX_TIMEOUT,
        eval_server_port: int = DEFAULT_EVAL_SERVER_PORT,
        cache_dir: str | Path | None = None,
        instance_id: str = "",
    ) -> None:
        self.image = image
        self.repo_path = repo_path
        self.enable_gortex = enable_gortex
        self.gortex_binary = Path(gortex_binary) if gortex_binary else None
        self.gortex_timeout = gortex_timeout
        self.eval_server_port = eval_server_port
        self.cache_dir = Path(cache_dir) if cache_dir else DEFAULT_CACHE_DIR
        self.instance_id = instance_id

        self._client: docker.DockerClient | None = None
        self._container: Any = None  # docker.models.containers.Container
        self._gortex_ready = False
        self._setup_error: str | None = None
        self.index_time: float = 0.0

    # -- public API ----------------------------------------------------------

    def setup(self) -> dict[str, Any] | None:
        """Launch container, copy gortex + bridge scripts, start eval-server.

        Returns ``None`` on success, or a failure result dict on error.
        The caller should check the return value and skip agent execution
        when a failure dict is returned.
        """
        try:
            self._launch_container()
        except Exception as exc:
            return self._record_failure(f"Container launch failed: {exc}")

        if not self.enable_gortex:
            logger.info("Gortex disabled for this instance, skipping tool setup")
            return None

        try:
            start = time.time()
            self._copy_gortex_binary()
            self._copy_bridge_scripts()
            self._restore_or_skip_cache()
            self._start_eval_server()
            self._wait_for_health()
            self.index_time = time.time() - start
            self._gortex_ready = True
            logger.info(
                "Gortex environment ready for %s in %.1fs",
                self.instance_id,
                self.index_time,
            )
        except _SetupTimeout as exc:
            return self._record_failure(str(exc))
        except Exception as exc:
            return self._record_failure(f"Gortex setup failed: {exc}")

        return None

    def extract_patch(self) -> str:
        """Extract the agent's patch via ``git diff`` inside the container.

        Returns the diff string, or an empty string on failure.
        """
        if self._container is None:
            return ""
        try:
            exit_code, output = self._container.exec_run(
                ["git", "diff"],
                workdir=self.repo_path,
            )
            if exit_code == 0:
                return output.decode("utf-8", errors="replace")
            logger.warning("git diff exited %d: %s", exit_code, output[:500])
        except Exception as exc:
            logger.warning("Patch extraction failed: %s", exc)
        return ""

    def teardown(self) -> None:
        """Stop and remove the container."""
        if self._container is not None:
            try:
                self._container.stop(timeout=5)
            except Exception:
                pass
            try:
                self._container.remove(force=True)
            except Exception:
                pass
            self._container = None
        if self._client is not None:
            try:
                self._client.close()
            except Exception:
                pass
            self._client = None

    def exec_run(self, cmd: str | list[str], **kwargs: Any) -> tuple[int, str]:
        """Execute a command inside the container.

        Returns (exit_code, output_string).
        """
        if self._container is None:
            return (1, "Container not running")
        if isinstance(cmd, str):
            cmd = ["bash", "-c", cmd]
        exit_code, output = self._container.exec_run(cmd, **kwargs)
        return exit_code, output.decode("utf-8", errors="replace")

    @property
    def is_ready(self) -> bool:
        """Whether the environment is fully set up and gortex is healthy."""
        return self._container is not None and (
            not self.enable_gortex or self._gortex_ready
        )

    @property
    def setup_error(self) -> str | None:
        """Description of setup failure, if any."""
        return self._setup_error

    # -- private helpers -----------------------------------------------------

    def _launch_container(self) -> None:
        """Create and start a Docker container from the configured image."""
        self._client = docker.from_env()
        logger.info("Launching container from image %s", self.image)
        self._container = self._client.containers.run(
            self.image,
            command="sleep infinity",
            detach=True,
            working_dir=self.repo_path,
        )
        logger.info("Container %s started", self._container.short_id)

    def _copy_gortex_binary(self) -> None:
        """Copy the gortex binary into the container."""
        if self.gortex_binary is None:
            logger.info("No gortex binary path specified, assuming pre-installed")
            return
        if not self.gortex_binary.is_file():
            raise FileNotFoundError(f"Gortex binary not found: {self.gortex_binary}")

        logger.info("Copying gortex binary into container")
        self._put_file_in_container(
            self.gortex_binary,
            GORTEX_BINARY_CONTAINER_PATH,
            executable=True,
        )
        # Verify binary is accessible and executable
        exit_code, output = self._container.exec_run(
            ["ls", "-la", GORTEX_BINARY_CONTAINER_PATH]
        )
        if exit_code != 0:
            raise RuntimeError(
                f"Gortex binary not found in container after copy: {output.decode()}"
            )
        # Check if binary can actually run (catches arch mismatch, missing libs)
        exit_code, output = self._container.exec_run(
            [GORTEX_BINARY_CONTAINER_PATH, "--help"]
        )
        if exit_code != 0:
            # Try to get more info
            _, file_output = self._container.exec_run(
                ["file", GORTEX_BINARY_CONTAINER_PATH]
            )
            _, ldd_output = self._container.exec_run(
                ["bash", "-c", f"ldd {GORTEX_BINARY_CONTAINER_PATH} 2>&1 || true"]
            )
            raise RuntimeError(
                f"Gortex binary cannot execute in container.\n"
                f"  file: {file_output.decode().strip()}\n"
                f"  ldd: {ldd_output.decode().strip()}\n"
                f"  error: {output.decode().strip()}"
            )
        logger.info("Gortex binary verified at %s", GORTEX_BINARY_CONTAINER_PATH)

    def _copy_bridge_scripts(self) -> None:
        """Copy tool bridge bash scripts into /usr/local/bin/ in the container."""
        bridge_dir = Path(__file__).resolve().parent.parent / "bridge"
        copied = 0
        for script_name in _BRIDGE_SCRIPTS:
            script_path = bridge_dir / script_name
            if not script_path.is_file():
                logger.warning("Bridge script not found: %s", script_path)
                continue
            self._put_file_in_container(
                script_path,
                f"{BRIDGE_SCRIPTS_CONTAINER_DIR}/{script_name}",
                executable=True,
            )
            copied += 1
        logger.info("Copied %d bridge scripts into container", copied)

    def _restore_or_skip_cache(self) -> None:
        """Mount/copy a cached index into the container if one exists."""
        repo_name, commit_hash = self._get_repo_identity()
        cache_key = _make_cache_key(repo_name, commit_hash)
        cache_path = self.cache_dir / cache_key

        if not cache_path.is_dir():
            logger.info("No cached index for %s, eval-server will index fresh", cache_key)
            return

        tarball = cache_path / "index.tar.gz"
        if not tarball.is_file():
            logger.info("Cache dir exists but no tarball for %s, skipping", cache_key)
            return

        logger.info("Restoring cached index %s into container", cache_key)
        try:
            cache_dest = "/root/.gortex-cache"
            self._container.exec_run(["mkdir", "-p", cache_dest])
            with open(tarball, "rb") as f:
                self._container.put_archive(cache_dest, f.read())
            logger.info("Cached index restored to %s", cache_dest)
        except Exception as exc:
            logger.warning("Cache restore failed, will index fresh: %s", exc)

    def _start_eval_server(self) -> None:
        """Start ``gortex eval-server`` as a background process in the container."""
        cache_flag = ""
        cache_dest = "/root/.gortex-cache"
        # Check if cache was restored
        exit_code, _ = self._container.exec_run(["test", "-d", cache_dest])
        if exit_code == 0:
            cache_flag = f"--cache-dir {cache_dest}"

        cmd = (
            f"nohup /usr/local/bin/gortex eval-server "
            f"--port {self.eval_server_port} "
            f"--index {self.repo_path} "
            f"{cache_flag} "
            f"> /tmp/gortex-eval-server.log 2>&1 &"
        )
        logger.info("Starting eval-server on port %d", self.eval_server_port)
        self._container.exec_run(["bash", "-c", cmd], detach=True)

    def _wait_for_health(self) -> None:
        """Poll the eval-server health endpoint until ready or timeout.

        Raises ``_SetupTimeout`` if the server doesn't become healthy
        within ``self.gortex_timeout`` seconds.
        """
        deadline = time.time() + self.gortex_timeout
        health_url = f"http://127.0.0.1:{self.eval_server_port}/health"
        attempt = 0

        while time.time() < deadline:
            time.sleep(HEALTH_CHECK_INTERVAL)
            attempt += 1
            try:
                exit_code, output = self._container.exec_run(
                    ["curl", "-sf", health_url],
                )
                if exit_code == 0 and b'"status"' in output and b'"ok"' in output:
                    elapsed = self.gortex_timeout - (deadline - time.time())
                    logger.info(
                        "Eval-server healthy after %d attempts (%.1fs)",
                        attempt,
                        elapsed,
                    )
                    return
            except Exception:
                pass

        # Grab server logs for diagnostics
        try:
            _, log_output = self._container.exec_run(
                ["tail", "-30", "/tmp/gortex-eval-server.log"],
            )
            log_tail = log_output.decode("utf-8", errors="replace")[-1000:]
        except Exception:
            log_tail = "(unavailable)"

        raise _SetupTimeout(
            f"Eval-server health check timed out after {self.gortex_timeout}s "
            f"for instance {self.instance_id}. Server log tail:\n{log_tail}"
        )

    def _get_repo_identity(self) -> tuple[str, str]:
        """Extract (repo_name, commit_hash) from the container's /testbed repo."""
        _, repo_out = self._container.exec_run(
            ["bash", "-c", "basename $(git remote get-url origin 2>/dev/null || basename $(pwd)) .git"],
            workdir=self.repo_path,
        )
        _, commit_out = self._container.exec_run(
            ["bash", "-c", "git rev-parse HEAD 2>/dev/null || echo unknown"],
            workdir=self.repo_path,
        )
        repo_name = repo_out.decode("utf-8", errors="replace").strip() or "unknown"
        commit_hash = commit_out.decode("utf-8", errors="replace").strip() or "unknown"
        return repo_name, commit_hash

    def _put_file_in_container(
        self,
        local_path: Path,
        container_path: str,
        *,
        executable: bool = False,
    ) -> None:
        """Copy a local file into the container using the Docker API.

        Falls back to ``podman cp`` / ``docker cp`` if the API method fails.
        """
        dest_dir = str(Path(container_path).parent)

        # Try Docker SDK put_archive first
        try:
            data = local_path.read_bytes()
            tar_stream = io.BytesIO()
            with tarfile.open(fileobj=tar_stream, mode="w") as tar:
                info = tarfile.TarInfo(name=Path(container_path).name)
                info.size = len(data)
                if executable:
                    info.mode = 0o755
                tar.addfile(info, io.BytesIO(data))
            tar_stream.seek(0)
            self._container.put_archive(dest_dir, tar_stream)

            # Verify the file actually landed
            exit_code, _ = self._container.exec_run(["test", "-f", container_path])
            if exit_code == 0:
                if executable:
                    self._container.exec_run(["chmod", "+x", container_path])
                return
            logger.warning("put_archive succeeded but file not found, trying cp fallback")
        except Exception as exc:
            logger.warning("put_archive failed (%s), trying cp fallback", exc)

        # Fallback: use podman/docker cp via subprocess
        import subprocess
        container_id = self._container.short_id
        try:
            subprocess.run(
                ["podman", "cp", str(local_path), f"{container_id}:{container_path}"],
                check=True, capture_output=True, timeout=30,
            )
        except (subprocess.CalledProcessError, FileNotFoundError):
            # Try docker cp as last resort
            subprocess.run(
                ["docker", "cp", str(local_path), f"{container_id}:{container_path}"],
                check=True, capture_output=True, timeout=30,
            )
        if executable:
            self._container.exec_run(["chmod", "+x", container_path])

    def _record_failure(self, message: str) -> dict[str, Any]:
        """Record a setup failure and return a result dict for the runner."""
        logger.error("Setup failure for %s: %s", self.instance_id, message)
        self._setup_error = message
        return {
            "instance_id": self.instance_id,
            "exit_status": "setup_failure",
            "setup_error": message,
            "submission": "",
        }


class _SetupTimeout(Exception):
    """Raised internally when the eval-server health check times out."""
