"""The docker orchestrator (control plane) as a single, fixed-name container
(PRD 0070).

`DockerOrchestrator` is the docker implementation of the backend-neutral
`Orchestrator` service. The lean control-plane container joins the `--internal`
control network only (agents are never on it, so they have no L3 route to it)
plus a host-loopback publish for the CLI. It is the sole opener of
`bot-bottle.db` and the sole holder of the signing key (#469).
"""

from __future__ import annotations

import os
import time
from pathlib import Path

from ... import log
from ... import resources
from .util import run_docker
from ...paths import (
    ORCHESTRATOR_TOKEN_ENV,
    bot_bottle_root,
)
from ...gateway import GatewayError
from ...orchestrator.lifecycle import (
    DEFAULT_PORT,
    DEFAULT_STARTUP_TIMEOUT_SECONDS,
    Orchestrator,
    OrchestratorStartError,
    source_hash,
)

# The control plane's own container + the dedicated `--internal` control network
# the gateway reaches it over. The orchestrator + gateway join it, agents never
# do, so agents have no route to the control plane (PRD 0070 "Separating the
# planes"). Same name across docker + macOS.
ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1"
ORCHESTRATOR_NETWORK = "bot-bottle-orchestrator"
ORCHESTRATOR_IMAGE = os.environ.get(
    "BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
)
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
# Baked as a container label so `ensure_running` can detect whether the running
# orchestrator is executing the current bind-mounted source.
ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash"

# The bind-mount path for the live control-plane source inside the container.
# PYTHONPATH points here so a code change takes effect on the next launch
# without an image rebuild.
_SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount (DB + state) inside the orchestrator. The
# control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT ->
# host_db_path()); it is the ONLY container with a handle on it (issue #469).
_ROOT_IN_CONTAINER = "/bot-bottle-root"

_HEALTH_POLL_SECONDS = 0.25


class DockerOrchestrator(Orchestrator):
    """The control plane as a single fixed-name container. `ensure_built` builds
    it from `Dockerfile.orchestrator`; `ensure_running` starts it on the control
    network + host loopback and blocks until `/health` answers."""

    def __init__(
        self,
        image_ref: str = ORCHESTRATOR_IMAGE,
        *,
        name: str = ORCHESTRATOR_NAME,
        label: str = ORCHESTRATOR_LABEL,
        port: int = DEFAULT_PORT,
        control_network: str = ORCHESTRATOR_NETWORK,
        repo_root: Path | None = None,
        host_root: Path | None = None,
        dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
    ) -> None:
        self.image_ref = image_ref
        self.name = name
        self.label = label
        self.port = port
        self.control_network = control_network
        # Build context / bind-mount source: the repo root in a checkout, a
        # staged copy from the installed wheel otherwise (bot_bottle.resources).
        self._repo_root = repo_root if repo_root is not None else resources.build_root()
        self._host_root = host_root or bot_bottle_root()
        self._dockerfile = dockerfile

    def url(self) -> str:
        """Host-side control-plane URL — the orchestrator's published loopback,
        which the CLI reaches."""
        return f"http://127.0.0.1:{self.port}"

    def gateway_url(self) -> str:
        """The URL the gateway's data plane resolves policy against — the
        orchestrator container by name on the control network (docker DNS,
        container↔container, no host firewall)."""
        return f"http://{self.name}:{DEFAULT_PORT}"

    def ensure_built(self) -> None:
        """Build the control-plane image from its Dockerfile, cache-aware (a
        no-op when nothing changed). No-op when no dockerfile is configured (a
        pre-pulled image). BOT_BOTTLE_NO_CACHE forces a full rebuild."""
        if self._dockerfile is None:
            return
        argv = ["docker", "build", "-t", self.image_ref,
                "-f", str(self._repo_root / self._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"{self._dockerfile} build failed: {proc.stderr.strip()}")

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

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

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

    def ensure_running(
        self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
    ) -> None:
        """Ensure the control-plane container is up on current source; block
        until healthy. Idempotent — a healthy orchestrator on current source is
        left untouched (its in-memory egress tokens survive). Raises
        `OrchestratorStartError` on startup timeout."""
        current_hash = source_hash(self._repo_root)
        if self.is_healthy() and self._source_current(current_hash):
            return
        log.info("starting orchestrator container", context={"name": self.name})
        self._run_container(current_hash)
        deadline = time.monotonic() + startup_timeout
        while time.monotonic() < deadline:
            if self.is_healthy():
                log.info("orchestrator healthy", context={"url": self.url()})
                return
            time.sleep(_HEALTH_POLL_SECONDS)
        raise OrchestratorStartError(
            f"orchestrator at {self.url()} did not become healthy "
            f"within {startup_timeout:g}s"
        )

    def _run_container(self, current_hash: str) -> None:
        """Start the lean control-plane container on the control network only,
        published to host loopback for the CLI. Idempotent (clears a stale
        fixed-name container first)."""
        self._ensure_control_network()
        run_docker(["docker", "rm", "--force", self.name])
        # The signing key comes through the shared provisioning contract (#476),
        # which fail-closes rather than yield an empty key that would run OPEN.
        _signing_key = self.control_plane_key()
        proc = run_docker([
            "docker", "run", "--detach",
            "--name", self.name,
            "--label", self.label,
            "--label", f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current_hash}",
            # Control network only — agents are never on it, so they have no
            # route to the control plane (the L3 block, not just the JWT).
            "--network", self.control_network,
            # Host CLI reaches the control plane here (loopback only). The
            # orchestrator listens on the fixed DEFAULT_PORT inside the
            # container; self.port is the host-side published port.
            "--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
            # Live control-plane source (code changes without an image rebuild).
            "--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
            "--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}",
            # The signing key — held ONLY by the orchestrator (it verifies
            # tokens); the gateway gets the pre-minted `gateway` JWT, never the
            # key (issue #469). Bare `--env NAME` keeps the value off argv.
            "--env", ORCHESTRATOR_TOKEN_ENV,
            self.image_ref,
            # Dockerfile.orchestrator ENTRYPOINT is `python3 -m bot_bottle.orchestrator`;
            # these are its args.
            "--host", "0.0.0.0", "--port", str(DEFAULT_PORT), "--broker", "stub",
        ], env={**os.environ, ORCHESTRATOR_TOKEN_ENV: _signing_key})
        if proc.returncode != 0:
            raise OrchestratorStartError(
                f"orchestrator container failed to start: {proc.stderr.strip()}"
            )

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


__all__ = [
    "DockerOrchestrator",
    "ORCHESTRATOR_NAME",
    "ORCHESTRATOR_LABEL",
    "ORCHESTRATOR_NETWORK",
    "ORCHESTRATOR_IMAGE",
    "ORCHESTRATOR_DOCKERFILE",
    "ORCHESTRATOR_SOURCE_HASH_LABEL",
]
