"""Container runtime abstraction — CLI-based wrapper for docker/podman/nerdctl."""

from __future__ import annotations

import json
import logging
import re
import shutil
import subprocess
from functools import lru_cache
from pathlib import Path
from typing import Protocol, runtime_checkable

from hatchdx import HdxError
from hatchdx.sandbox.models import ContainerConfig, ContainerStats

logger = logging.getLogger(__name__)


class SandboxError(HdxError):
    """Raised when a sandbox operation fails."""


# ---------------------------------------------------------------------------
# Abstract runtime protocol
# ---------------------------------------------------------------------------


@runtime_checkable
class ContainerRuntime(Protocol):
    """Protocol defining the interface every container runtime must implement.

    Implementations must provide methods for the full container lifecycle:
    build, create, start, stop, exec, and stats.
    """

    def is_available(self) -> bool:
        """Return True if the runtime is installed and reachable."""
        ...

    def build_image(
        self,
        project_path: str,
        tag: str,
        *,
        dockerfile: str | None = None,
    ) -> str:
        """Build a container image from *project_path*.

        Returns the image ID.  If *dockerfile* is provided, it is used as the
        Dockerfile content (written to a temporary file in *project_path*).
        """
        ...

    def create_container(
        self,
        image: str,
        config: ContainerConfig,
    ) -> str:
        """Create (but do not start) a container from *image* with *config*.

        Returns the container ID.
        """
        ...

    def start_container(self, container_id: str) -> None:
        """Start a previously created container."""
        ...

    def stop_container(self, container_id: str, *, remove: bool = True) -> None:
        """Stop a running container.  If *remove* is True, also remove it."""
        ...

    def exec_in_container(
        self,
        container_id: str,
        command: list[str],
        *,
        stdin_data: str | None = None,
        timeout: int | None = None,
        user: str | None = None,
    ) -> tuple[int, str]:
        """Execute *command* inside a running container.

        Returns ``(exit_code, output)``.  If *stdin_data* is provided it is
        piped to the command's stdin.  If *user* is provided, the command
        runs as that user (e.g. ``"root"``).
        """
        ...

    def get_stats(self, container_id: str) -> ContainerStats:
        """Return a resource usage snapshot for a running container."""
        ...


# ---------------------------------------------------------------------------
# CLI-based implementation
# ---------------------------------------------------------------------------


_HUMAN_BYTES_RE = re.compile(
    r"^\s*([\d.]+)\s*(B|KiB|MiB|GiB|TiB|KB|MB|GB|TB|kB)\s*$", re.IGNORECASE
)

_BYTE_MULTIPLIERS: dict[str, float] = {
    "b": 1,
    "kb": 1_000,
    "kib": 1_024,
    "mb": 1_000_000,
    "mib": 1_048_576,
    "gb": 1_000_000_000,
    "gib": 1_073_741_824,
    "tb": 1_000_000_000_000,
    "tib": 1_099_511_627_776,
}


def _parse_human_bytes(s: str) -> int:
    """Convert a human-readable byte string to integer bytes.

    Handles both binary (MiB, GiB) and decimal (MB, GB) units.

    >>> _parse_human_bytes("100MiB")
    104857600
    >>> _parse_human_bytes("1.5GB")
    1500000000
    """
    m = _HUMAN_BYTES_RE.match(s.strip())
    if not m:
        raise ValueError(f"Cannot parse byte string: {s!r}")
    value = float(m.group(1))
    unit = m.group(2).lower()
    multiplier = _BYTE_MULTIPLIERS.get(unit)
    if multiplier is None:
        raise ValueError(f"Unknown byte unit: {m.group(2)!r}")
    return int(value * multiplier)


def _parse_cli_stats(container_id: str, raw_json: str) -> ContainerStats:
    """Parse ``docker stats --format '{{json .}}'`` output into a ``ContainerStats``.

    The JSON output has fields like ``MemUsage: "100MiB / 512MiB"`` and
    ``CPUPerc: "12.50%"``.
    """
    try:
        data = json.loads(raw_json)
    except json.JSONDecodeError:
        return ContainerStats(container_id=container_id[:12])

    # Parse memory: "100MiB / 512MiB"
    mem_usage_bytes = 0
    mem_limit_bytes = 0
    mem_usage_str = data.get("MemUsage", "")
    if " / " in mem_usage_str:
        usage_part, limit_part = mem_usage_str.split(" / ", 1)
        try:
            mem_usage_bytes = _parse_human_bytes(usage_part)
        except ValueError:
            pass
        try:
            mem_limit_bytes = _parse_human_bytes(limit_part)
        except ValueError:
            pass

    # Parse CPU: "12.50%"
    cpu_percent = 0.0
    cpu_str = data.get("CPUPerc", "").strip().rstrip("%")
    try:
        cpu_percent = float(cpu_str)
    except ValueError:
        pass

    return ContainerStats(
        container_id=container_id[:12],
        memory_usage_bytes=mem_usage_bytes,
        memory_limit_bytes=mem_limit_bytes,
        cpu_percent=cpu_percent,
    )


def _config_to_cli_flags(config: ContainerConfig) -> list[str]:
    """Translate a ``ContainerConfig`` into CLI flags for ``docker create``."""
    flags: list[str] = []

    # Network
    if config.network_mode:
        flags.extend(["--network", config.network_mode])

    # Memory
    if config.mem_limit:
        flags.extend(["--memory", config.mem_limit])

    # CPU (period + quota)
    if config.cpu_period:
        flags.extend(["--cpu-period", str(config.cpu_period)])
    if config.cpu_quota:
        flags.extend(["--cpu-quota", str(config.cpu_quota)])

    # Volumes
    for host_path, mount_opts in config.volumes.items():
        bind = mount_opts.get("bind", host_path)
        mode = mount_opts.get("mode", "rw")
        flags.extend(["--volume", f"{host_path}:{bind}:{mode}"])

    # Tmpfs
    for mount_point, opts in config.tmpfs.items():
        if opts:
            flags.extend(["--tmpfs", f"{mount_point}:{opts}"])
        else:
            flags.extend(["--tmpfs", mount_point])

    # Labels
    for key, value in config.labels.items():
        flags.extend(["--label", f"{key}={value}"])

    # Environment
    for key, value in config.environment.items():
        flags.extend(["--env", f"{key}={value}"])

    # Interactive (stdin open)
    flags.append("--interactive")

    # Entrypoint
    if config.entrypoint is not None:
        flags.extend(["--entrypoint", " ".join(config.entrypoint)])

    # Ports
    for container_port, host_port in config.ports.items():
        flags.extend(["--publish", f"{host_port}:{container_port}"])

    # Extra hosts
    for host_entry in config.extra_hosts:
        flags.extend(["--add-host", host_entry])

    # DNS
    for dns in config.dns_servers:
        flags.extend(["--dns", dns])

    # Capabilities
    for cap in config.cap_add:
        flags.extend(["--cap-add", cap])

    return flags


class CLIContainerRuntime:
    """Container runtime that shells out to docker/podman/nerdctl via subprocess.

    This eliminates the need for the ``docker`` Python SDK — if the user has
    any OCI-compliant container CLI on PATH, ``hdx sandbox`` just works.
    """

    def __init__(self, binary: str = "docker") -> None:
        resolved = shutil.which(binary)
        self._binary = resolved if resolved else binary

    @property
    def binary(self) -> str:
        """The resolved path to the container runtime binary."""
        return self._binary

    def _run(
        self,
        args: list[str],
        *,
        input_data: str | None = None,
        timeout: int | None = None,
        check: bool = True,
    ) -> subprocess.CompletedProcess[str]:
        """Run a container CLI command and return the result.

        Wraps ``subprocess.run`` with error handling and ``SandboxError``
        wrapping.
        """
        cmd = [self._binary, *args]
        logger.debug("Running: %s", " ".join(cmd))
        try:
            result = subprocess.run(
                cmd,
                input=input_data,
                capture_output=True,
                text=True,
                timeout=timeout,
            )
            if check and result.returncode != 0:
                stderr = result.stderr.strip()
                raise SandboxError(
                    f"Command failed (exit {result.returncode}): "
                    f"{' '.join(cmd)}\n{stderr}"
                )
            return result
        except subprocess.TimeoutExpired as exc:
            raise SandboxError(
                f"Command timed out after {timeout}s: {' '.join(cmd)}"
            ) from exc
        except FileNotFoundError:
            raise SandboxError(
                f"Container runtime binary not found: {self._binary}\n\n"
                "Make sure docker, podman, or nerdctl is installed and on your PATH."
            )
        except SandboxError:
            raise
        except Exception as exc:
            raise SandboxError(
                f"Failed to run container command: {exc}"
            ) from exc

    # -- ContainerRuntime interface --

    def is_available(self) -> bool:
        """Return True if the binary exists and the daemon is reachable."""
        try:
            self._run(["info"], timeout=10)
            return True
        except SandboxError:
            return False

    def build_image(
        self,
        project_path: str,
        tag: str,
        *,
        dockerfile: str | None = None,
    ) -> str:
        """Build a container image from *project_path*.

        If *dockerfile* is a string, it is written to ``Dockerfile.hdx``
        inside *project_path* and used as the build context's Dockerfile.
        Returns the image ID.
        """
        path = Path(project_path)
        temp_dockerfile: Path | None = None

        args = ["build", "-t", tag]

        if dockerfile is not None:
            temp_dockerfile = path / "Dockerfile.hdx"
            temp_dockerfile.write_text(dockerfile)
            args.extend(["-f", "Dockerfile.hdx"])

        args.append(str(path))

        try:
            self._run(args)
            # Get the image ID
            result = self._run([
                "inspect", "--format", "{{.Id}}", tag
            ])
            return result.stdout.strip()
        except SandboxError:
            raise
        except Exception as exc:
            raise SandboxError(f"Failed to build image: {exc}") from exc
        finally:
            if temp_dockerfile is not None and temp_dockerfile.exists():
                temp_dockerfile.unlink()

    def create_container(
        self,
        image: str,
        config: ContainerConfig,
    ) -> str:
        """Create a container from *image* with *config*. Returns container ID."""
        args = ["create"]
        args.extend(_config_to_cli_flags(config))
        args.append(image)

        if config.command is not None:
            args.extend(config.command)

        try:
            result = self._run(args)
            return result.stdout.strip()
        except SandboxError:
            raise
        except Exception as exc:
            raise SandboxError(f"Failed to create container: {exc}") from exc

    def start_container(self, container_id: str) -> None:
        """Start a previously created container."""
        try:
            self._run(["start", container_id])
        except SandboxError:
            raise
        except Exception as exc:
            raise SandboxError(
                f"Failed to start container {container_id[:12]}: {exc}"
            ) from exc

    def stop_container(self, container_id: str, *, remove: bool = True) -> None:
        """Stop a running container. If *remove* is True, also remove it."""
        try:
            self._run(["stop", "-t", "5", container_id], check=False)
            if remove:
                self._run(["rm", "-f", container_id], check=False)
        except SandboxError:
            logger.warning("Error stopping container %s", container_id[:12])
        except Exception as exc:
            logger.warning(
                "Error stopping container %s: %s", container_id[:12], exc
            )

    def exec_in_container(
        self,
        container_id: str,
        command: list[str],
        *,
        stdin_data: str | None = None,
        timeout: int | None = None,
        user: str | None = None,
    ) -> tuple[int, str]:
        """Execute *command* inside a running container.

        Returns ``(exit_code, combined_output)``.
        """
        args = ["exec"]
        if user is not None:
            args.extend(["--user", user])
        if stdin_data is not None:
            args.append("-i")
        args.append(container_id)
        args.extend(command)

        try:
            result = self._run(
                args,
                input_data=stdin_data,
                timeout=timeout,
                check=False,
            )
            output = result.stdout
            if result.stderr:
                output = output + result.stderr if output else result.stderr
            return result.returncode, output
        except SandboxError:
            raise
        except Exception as exc:
            raise SandboxError(
                f"Failed to exec in container {container_id[:12]}: {exc}"
            ) from exc

    def exec_interactive(
        self,
        container_id: str,
        command: list[str],
    ) -> int:
        """Exec into a container with an interactive TTY attached.

        Unlike ``exec_in_container``, this inherits the parent process's
        stdin/stdout/stderr so the user gets a real interactive shell.
        Returns the process exit code.
        """
        cmd = [self._binary, "exec", "-it", container_id, *command]
        logger.debug("Running interactive: %s", " ".join(cmd))
        try:
            result = subprocess.run(cmd)
            return result.returncode
        except FileNotFoundError:
            raise SandboxError(
                f"Container runtime binary not found: {self._binary}\n\n"
                "Make sure docker, podman, or nerdctl is installed and on your PATH."
            )

    def get_stats(self, container_id: str) -> ContainerStats:
        """Return a resource usage snapshot for *container_id*."""
        try:
            result = self._run([
                "stats", "--no-stream", "--format", "{{json .}}", container_id
            ])
            return _parse_cli_stats(container_id, result.stdout.strip())
        except SandboxError:
            raise
        except Exception as exc:
            raise SandboxError(
                f"Failed to get stats for container {container_id[:12]}: {exc}"
            ) from exc


# ---------------------------------------------------------------------------
# Runtime auto-detection
# ---------------------------------------------------------------------------

_RUNTIME_CANDIDATES = ("docker", "podman", "nerdctl")


@lru_cache(maxsize=1)
def _find_runtime_binary() -> str | None:
    """Scan PATH for docker, podman, or nerdctl (in preference order)."""
    for candidate in _RUNTIME_CANDIDATES:
        if shutil.which(candidate) is not None:
            return candidate
    return None


def detect_runtime(override: str | None = None) -> CLIContainerRuntime:
    """Detect an available container runtime.

    If *override* is provided, use it directly (binary name or absolute path)
    and verify that the daemon is reachable.  Otherwise, auto-detect from PATH.

    Raises ``SandboxError`` with installation instructions if no runtime is
    found or the daemon is not running.
    """
    if override is not None:
        rt = CLIContainerRuntime(binary=override)
        if rt.is_available():
            return rt
        # Binary exists on PATH but daemon might not be running
        if shutil.which(override) is not None or Path(override).is_file():
            raise SandboxError(
                f"Found '{override}' on PATH but the daemon is not reachable.\n\n"
                f"Make sure the {override} daemon is running."
            )
        raise SandboxError(
            f"Container runtime '{override}' not found on PATH.\n\n"
            "Provide a valid binary name (docker, podman, nerdctl) or an absolute path."
        )

    binary = _find_runtime_binary()
    if binary is not None:
        rt = CLIContainerRuntime(binary=binary)
        if rt.is_available():
            return rt
        raise SandboxError(
            f"Found '{binary}' on PATH but the daemon is not reachable.\n\n"
            f"Make sure the {binary} daemon is running."
        )

    raise SandboxError(
        "No container runtime found.\n\n"
        "hdx sandbox requires an OCI-compliant container runtime.\n\n"
        "Install one of the following:\n"
        "  Docker:  https://www.docker.com/products/docker-desktop\n"
        "  Podman:  https://podman.io/getting-started/installation\n"
        "  nerdctl: https://github.com/containerd/nerdctl/releases"
    )
