"""Orchestrator + gateway lifecycle (PRD 0070, docker slice).

Runs both the orchestrator control plane and the gateway data plane inside
a single `bot-bottle-infra` container on the shared gateway network —
matching the structure already used by the macOS and Firecracker backends.
`gateway_init` is PID 1 and supervises both; the infra container is an
idempotent per-host singleton.

The combined container replaces the prior two-container split
(bot-bottle-orchestrator + bot-bottle-orch-gateway). The host CLI reaches
the control plane via a published loopback port; gateway daemons reach it
over 127.0.0.1 (same container).
"""

from __future__ import annotations

import hashlib
import os
import time
import urllib.error
import urllib.request
from pathlib import Path

from .. import log
from ..docker_cmd import run_docker
from ..paths import CONTROL_PLANE_TOKEN_ENV, bot_bottle_root, host_control_plane_token
from ..supervise import DB_PATH_IN_CONTAINER
from .gateway import (
    GATEWAY_CA_VOLUME,
    GATEWAY_DOCKERFILE,
    GATEWAY_IMAGE,
    GATEWAY_NETWORK,
    GatewayError,
    MITMPROXY_HOME,
    _host_db_dir,
)

DEFAULT_PORT = 8099
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0

INFRA_NAME = "bot-bottle-infra"
INFRA_LABEL = "bot-bottle-infra=1"
# The combined infra image: gateway data plane + orchestrator content.
# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator).
INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest")
INFRA_DOCKERFILE = "Dockerfile.infra"
# Baked as a container label so `ensure_running` can detect whether the
# running container is executing the current bind-mounted source.
INFRA_SOURCE_HASH_LABEL = "bot-bottle-infra-source-hash"

# Orchestrator image: the single canonical definition of the control-plane
# content (lean: python:3.12-slim + bot_bottle package, no mitmproxy/git).
# Used as a build intermediate: `Dockerfile.infra` COPY --from this image.
ORCHESTRATOR_IMAGE = os.environ.get(
    "BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
)
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"

# The gateway daemons + orchestrator the infra container runs.
# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to
# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS).
_INFRA_DAEMONS = "egress,git-http,supervise,orchestrator"

# The bind-mount path for the live control-plane source inside the
# container. Separate from /app so the gateway's baked scripts
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
_SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount inside the container (DB + state).
_ROOT_IN_CONTAINER = "/bot-bottle-root"

# The supervise daemon writes proposals into the host DB directory.
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)

_HEALTH_POLL_SECONDS = 0.25
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0

_REPO_ROOT = Path(__file__).resolve().parents[2]


class OrchestratorStartError(RuntimeError):
    """The infra container did not become healthy within the timeout."""


def source_hash(repo_root: Path) -> str:
    """Content hash of the orchestrator's bind-mounted Python source (the
    `bot_bottle` package the control-plane process imports). Changes only
    when the code that would actually run changes — `ensure_running`
    recreates the container on a mismatch so a code change takes effect,
    but leaves a healthy up-to-date container alone to preserve in-memory
    egress tokens."""
    h = hashlib.sha256()
    for path in sorted((repo_root / "bot_bottle").rglob("*.py")):
        h.update(str(path.relative_to(repo_root)).encode())
        h.update(path.read_bytes())
    return h.hexdigest()


class OrchestratorService:
    """Manages the single per-host infra container (control plane + gateway).
    Callers only need `ensure_running()` + `url`.

    `infra_name` / `infra_label` let backends run independent infra containers
    on the same host without name collisions (e.g. isolated integration tests
    that can't share the production INFRA_NAME singleton)."""

    def __init__(
        self,
        *,
        port: int = DEFAULT_PORT,
        network: str = GATEWAY_NETWORK,
        image: str = INFRA_IMAGE,
        repo_root: Path = _REPO_ROOT,
        host_root: Path | None = None,
        infra_name: str = INFRA_NAME,
        infra_label: str = INFRA_LABEL,
    ) -> None:
        self.port = port
        self.network = network
        self.image = image
        self._repo_root = repo_root
        self._host_root = host_root or bot_bottle_root()
        self._infra_name = infra_name
        self._infra_label = infra_label

    @property
    def url(self) -> str:
        """Host-side control-plane URL (published loopback port)."""
        return f"http://127.0.0.1:{self.port}"

    def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
        try:
            with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
                return resp.status == 200
        except (urllib.error.URLError, TimeoutError, OSError):
            return False

    def _container_running(self, name: str) -> bool:
        proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
        return name in proc.stdout.split()

    def _infra_source_current(self, current_hash: str) -> bool:
        """True iff the running infra container was started from the current
        bind-mounted source. Mirrors the macOS backend's `_source_current`."""
        if not self._container_running(self._infra_name):
            return False
        proc = run_docker([
            "docker", "inspect", "--format",
            "{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}",
            self._infra_name,
        ])
        if proc.returncode != 0:
            return True  # can't compare → don't churn a working container
        return proc.stdout.strip() == current_hash

    def _ensure_network(self) -> None:
        if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
            return
        proc = run_docker(["docker", "network", "create", self.network])
        if proc.returncode != 0 and "already exists" not in proc.stderr:
            raise GatewayError(
                f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
            )

    def _build_images(self) -> None:
        """Build the gateway base, the orchestrator intermediate, then the
        infra image. All are cache-aware: a no-op when nothing changed."""
        for tag, dockerfile in (
            (GATEWAY_IMAGE, GATEWAY_DOCKERFILE),
            (ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE),
            (self.image, INFRA_DOCKERFILE),
        ):
            argv = ["docker", "build", "-t", tag,
                    "-f", str(self._repo_root / dockerfile),
                    str(self._repo_root)]
            if os.environ.get("BOT_BOTTLE_NO_CACHE"):
                argv.insert(2, "--no-cache")
            proc = run_docker(argv)
            if proc.returncode != 0:
                raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}")

    def _run_infra_container(self, current_hash: str) -> None:
        """Start the combined infra container (idempotent: clears a stale
        fixed-name container first). Labels the container with `current_hash`
        so a later `ensure_running` can detect a real code change."""
        self._ensure_network()
        run_docker(["docker", "rm", "--force", self._infra_name])
        proc = run_docker([
            "docker", "run", "--detach",
            "--name", self._infra_name,
            "--label", self._infra_label,
            "--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}",
            "--network", self.network,
            # Host CLI reaches the control plane here (loopback only).
            # gateway_init always starts the orchestrator on DEFAULT_PORT (8099)
            # inside the container; self.port is the host-side published port.
            "--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
            # Persist the mitmproxy CA so it survives container recreation.
            "--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
            # Shared supervise DB (same file the operator reads over HTTP).
            "--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
            "--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
            # Live control-plane source, mounted to a path that does not
            # overlay the gateway's baked /app scripts.
            "--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
            # PYTHONPATH lets the orchestrator (and other Python daemons)
            # import the live source ahead of the installed package.
            "--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
            # Orchestrator registry DB on the host (sole writer: control plane).
            "--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
            "--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
            # Control-plane secret: required by the orchestrator (to enforce)
            # and by the gateway daemons (to present on /resolve calls).
            "--env", CONTROL_PLANE_TOKEN_ENV,
            # Gateway daemons reach the orchestrator over loopback at its
            # fixed internal port (DEFAULT_PORT), independent of self.port.
            "--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}",
            # Opt the orchestrator into gateway_init's supervise tree.
            "--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}",
            self.image,
        ], env={**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()})
        if proc.returncode != 0:
            raise OrchestratorStartError(
                f"infra container failed to start: {proc.stderr.strip()}"
            )

    def ensure_running(
        self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
    ) -> str:
        """Ensure the infra container (control plane + gateway) is up; return
        the host control-plane URL. Idempotent — a healthy container on current
        source is left untouched. Raises `OrchestratorStartError` on timeout."""
        self._build_images()

        current_hash = source_hash(self._repo_root)
        if self.is_healthy() and self._infra_source_current(current_hash):
            return self.url

        log.info("starting infra container", context={"name": self._infra_name})
        self._run_infra_container(current_hash)

        deadline = time.monotonic() + startup_timeout
        while time.monotonic() < deadline:
            if self.is_healthy():
                log.info("infra container healthy", context={"url": self.url})
                return self.url
            time.sleep(_HEALTH_POLL_SECONDS)
        raise OrchestratorStartError(
            f"infra container at {self.url} did not become healthy within {startup_timeout:g}s"
        )

    def stop(self) -> None:
        """Remove the infra container (idempotent)."""
        run_docker(["docker", "rm", "--force", self._infra_name])


__all__ = [
    "OrchestratorService",
    "OrchestratorStartError",
    "INFRA_NAME",
    "INFRA_IMAGE",
    "INFRA_SOURCE_HASH_LABEL",
    "ORCHESTRATOR_IMAGE",
    "DEFAULT_PORT",
    "DEFAULT_STARTUP_TIMEOUT_SECONDS",
    "source_hash",
]
