"""The host-side orchestrator (control-plane) service + shared lifecycle pieces
(PRD 0070).

`Orchestrator` is the backend-neutral host-side contract for the control plane,
mirroring `Gateway` for the data plane: build its image/rootfs, bring it up,
report where the CLI and the gateway reach it, mint the gateway's token, tear it
down. One concrete impl per backend (`backend/*/orchestrator.py`); the host
composes it with the `Gateway` service.

This module also holds the backend-neutral constants those impls build on: the
control-plane port, the startup timeout, the start-error, and the `source_hash`
used to detect a code change. (The in-guest control-plane *core* — the registry
+ broker object the running process wraps — is `service.OrchestratorCore`.)
"""

from __future__ import annotations

import abc
import hashlib
import urllib.error
import urllib.request
from pathlib import Path

from ..trust_domain import ControlPlaneProvisioning

DEFAULT_PORT = 8099
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
# A single /health probe's timeout (the poll loop repeats it up to the startup
# timeout). Short so a wedged control plane fails fast per attempt.
DEFAULT_HEALTH_TIMEOUT_SECONDS = 1.0


class OrchestratorStartError(RuntimeError):
    """The control plane 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 — a backend's `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 Orchestrator(abc.ABC):
    """Provision + interact with the per-host orchestrator (control plane).

    One concrete impl per backend (`backend/*/orchestrator.py`); the host
    composes it with the `Gateway` service. The orchestrator is the **sole**
    holder of the signing key and the **sole** opener of `bot-bottle.db` (#469),
    so it — not the gateway — mints the gateway's role-scoped token.
    Backend-neutral."""

    # The shared control-plane auth provisioning contract (#476). Every backend
    # gets its signing key + gateway token through this one seam rather than
    # re-deriving the wiring; it is fail-closed for every backend — the
    # orchestrator never starts without its signing key.
    provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()

    def ensure_built(self) -> None:
        """Ensure the orchestrator's image / rootfs exists, building it if
        needed. Default: nothing to build (e.g. a pre-pulled image). Call before
        `ensure_running`."""
        return

    @abc.abstractmethod
    def ensure_running(
        self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
    ) -> None:
        """Start the control plane if it isn't already healthy on the current
        source, then block until `/health` answers. Idempotent — a healthy,
        current control plane is left running so its in-memory egress tokens
        survive (#381). Raises `OrchestratorStartError` on startup timeout."""

    @abc.abstractmethod
    def is_running(self) -> bool:
        """True iff the orchestrator instance (container / VM) is currently up."""

    @abc.abstractmethod
    def stop(self) -> None:
        """Remove the orchestrator. Idempotent — absent is success."""

    @abc.abstractmethod
    def url(self) -> str:
        """The host-facing control-plane URL the CLI reaches the orchestrator
        at (docker: a host-loopback publish; macOS/firecracker: the guest's
        control-network / guest IP)."""

    @abc.abstractmethod
    def gateway_url(self) -> str:
        """The control-plane URL the gateway's data plane resolves policy
        against. May differ from `url()` — docker reaches the orchestrator by
        its container-DNS name on the control network, not the host loopback."""

    def is_healthy(self, *, timeout: float = DEFAULT_HEALTH_TIMEOUT_SECONDS) -> bool:
        """True iff the control plane answers 200 on `/health` at `url()`.
        Backends may override (e.g. to also check the VM process is alive)."""
        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 mint_gateway_token(self) -> str:
        """Mint a role-scoped `gateway` JWT from the host signing key for the
        gateway to present. The orchestrator holds the key; the gateway never
        does (#469). Routed through the shared provisioning contract (#476), so
        the same host token file is the single source of truth across backends."""
        return self.provisioning.gateway_token()

    def control_plane_key(self) -> str:
        """The raw signing key the control-plane *process* must receive — the ONE
        place a backend obtains it (docker/macOS inject it as `key_env`;
        firecracker pushes it to the guest). Fail-closed via the provisioning
        contract: it raises rather than yield an empty key that would run the
        server OPEN (#476)."""
        return self.provisioning.orchestrator_key()


__all__ = [
    "DEFAULT_PORT",
    "DEFAULT_STARTUP_TIMEOUT_SECONDS",
    "DEFAULT_HEALTH_TIMEOUT_SECONDS",
    "OrchestratorStartError",
    "source_hash",
    "Orchestrator",
    "ControlPlaneProvisioning",
]
