"""Unit: the docker orchestrator (control plane) container lifecycle (PRD 0070)."""

from __future__ import annotations

import unittest
import urllib.error
from unittest.mock import MagicMock, Mock, patch

from bot_bottle.backend.docker.orchestrator import (
    ORCHESTRATOR_NAME,
    ORCHESTRATOR_NETWORK,
    ORCHESTRATOR_SOURCE_HASH_LABEL,
    DockerOrchestrator,
)
from bot_bottle.gateway import GatewayError
from bot_bottle.orchestrator.lifecycle import OrchestratorStartError, source_hash

_ORCH = "bot_bottle.backend.docker.orchestrator"
_RUN = f"{_ORCH}.run_docker"
_SLEEP = f"{_ORCH}.time.sleep"
_MONOTONIC = f"{_ORCH}.time.monotonic"
# The signing key is read through the shared provisioning contract (#476); patch
# its host-canonical key file read to keep it off the real host file.
_TOKEN = "bot_bottle.trust_domain.host_signing_key"
# The ABC's is_healthy probes /health via urllib in the lifecycle module.
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"


def _health(status: int) -> MagicMock:
    m = MagicMock()
    m.__enter__.return_value.status = status
    return m


def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock:
    return Mock(returncode=returncode, stdout=stdout, stderr=stderr)


class TestDockerOrchestrator(unittest.TestCase):
    def setUp(self) -> None:
        self.orch = DockerOrchestrator(port=8099)
        # _run_container seeds the signing key; keep it off the real host file.
        p = patch(_TOKEN, return_value="signing-key")
        p.start()
        self.addCleanup(p.stop)

    def test_url_is_host_loopback(self) -> None:
        self.assertEqual("http://127.0.0.1:8099", self.orch.url())

    def test_gateway_url_is_the_container_dns_name(self) -> None:
        # The gateway reaches the orchestrator by name on the control network.
        self.assertEqual(f"http://{ORCHESTRATOR_NAME}:8099", self.orch.gateway_url())

    def test_is_healthy_probes_health_on_the_loopback_url(self) -> None:
        with patch(_URLOPEN, return_value=_health(200)):
            self.assertTrue(self.orch.is_healthy())
        with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
            self.assertFalse(self.orch.is_healthy())

    def test_noop_when_healthy_and_source_unchanged(self) -> None:
        # A healthy orchestrator on current source is left alone — recreating it
        # on every launch drops in-memory egress tokens (#381).
        current = source_hash(self.orch._repo_root)

        def fake(argv: list[str], **_kw: object) -> Mock:
            if argv[:2] == ["docker", "ps"]:
                return _proc(stdout=ORCHESTRATOR_NAME)
            if argv[:2] == ["docker", "inspect"]:
                return _proc(stdout=current)
            return _proc()

        with patch(_URLOPEN, return_value=_health(200)), \
                patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
            self.orch.ensure_running()
        self.assertEqual([], [c.args[0] for c in run.call_args_list
                              if c.args[0][:2] == ["docker", "run"]])

    def test_recreates_when_source_changed(self) -> None:
        def fake(argv: list[str], **_kw: object) -> Mock:
            if argv[:2] == ["docker", "ps"]:
                return _proc(stdout=ORCHESTRATOR_NAME)
            if argv[:2] == ["docker", "inspect"]:
                return _proc(stdout="stale-hash")
            return _proc()

        with patch(_URLOPEN, return_value=_health(200)), \
                patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
            self.orch.ensure_running()
        runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]]
        self.assertEqual(1, len(runs))
        self.assertIn(ORCHESTRATOR_NAME, runs[0])
        current = source_hash(self.orch._repo_root)
        self.assertIn(f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current}", runs[0])

    def test_starts_when_absent_on_control_net_and_loopback(self) -> None:
        def fake(argv: list[str], **_kw: object) -> Mock:
            if argv[:2] == ["docker", "ps"]:
                return _proc(stdout="")
            return _proc()

        with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
                patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
            self.orch.ensure_running()
        argv = next(c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"])
        self.assertIn(ORCHESTRATOR_NAME, argv)
        # Control network only — agents are never on it.
        self.assertEqual(ORCHESTRATOR_NETWORK, argv[argv.index("--network") + 1])
        # Published on loopback for the CLI, mapped to the fixed internal 8099.
        self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1])
        # The lean control plane: no mitmproxy CA mount, no gateway daemons.
        self.assertFalse([a for a in argv if a.endswith(":/home/mitmproxy/.mitmproxy")])
        self.assertNotIn("BOT_BOTTLE_GATEWAY_DAEMONS", " ".join(argv))
        # Orchestrator entrypoint args (image ENTRYPOINT is `-m bot_bottle.orchestrator`).
        self.assertIn("--broker", argv)
        self.assertIn("stub", argv)

    def test_publish_maps_host_port_to_fixed_internal_port(self) -> None:
        def fake(argv: list[str], **_kw: object) -> Mock:
            return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()

        orch = DockerOrchestrator(port=20001)
        with patch(_TOKEN, return_value="k"), \
                patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
                patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
            orch.ensure_running()
        argv = next(c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"])
        self.assertEqual("127.0.0.1:20001:8099", argv[argv.index("--publish") + 1])

    def test_raises_on_startup_timeout(self) -> None:
        with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
                patch(_RUN, return_value=_proc()), \
                patch(_SLEEP), patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]):
            with self.assertRaises(OrchestratorStartError):
                self.orch.ensure_running(startup_timeout=1.0)

    def test_noop_when_healthy_and_inspect_fails(self) -> None:
        def fake(argv: list[str], **_kw: object) -> Mock:
            if argv[:2] == ["docker", "ps"]:
                return _proc(stdout=ORCHESTRATOR_NAME)
            if argv[:2] == ["docker", "inspect"]:
                return _proc(returncode=1, stderr="daemon error")
            return _proc()

        with patch(_URLOPEN, return_value=_health(200)), \
                patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
            self.orch.ensure_running()
        self.assertEqual([], [c.args[0] for c in run.call_args_list
                              if c.args[0][:2] == ["docker", "run"]])

    def test_creates_control_network_internal(self) -> None:
        def fake(argv: list[str], **_kw: object) -> Mock:
            if argv[:3] == ["docker", "network", "inspect"]:
                return _proc(returncode=1, stderr="not found")
            if argv[:2] == ["docker", "ps"]:
                return _proc(stdout="")
            return _proc()

        with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
                patch(_RUN, side_effect=fake) as run, patch(_SLEEP):
            self.orch.ensure_running()
        creates = [c.args[0] for c in run.call_args_list if c.args[0][:3] == ["docker", "network", "create"]]
        control = [c for c in creates if ORCHESTRATOR_NETWORK in c]
        self.assertEqual(1, len(control))
        self.assertIn("--internal", control[0])

    def test_ensure_built_builds_the_orchestrator_image(self) -> None:
        with patch(_RUN, return_value=_proc()) as run:
            self.orch.ensure_built()
        builds = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "build"]]
        self.assertEqual(1, len(builds))
        self.assertTrue(any(a.endswith("Dockerfile.orchestrator") for a in builds[0]))

    def test_ensure_built_raises_on_build_failure(self) -> None:
        with patch(_RUN, return_value=_proc(returncode=1, stderr="no space left")):
            with self.assertRaises(GatewayError):
                self.orch.ensure_built()

    def test_ensure_built_noop_without_dockerfile(self) -> None:
        orch = DockerOrchestrator(dockerfile=None)
        with patch(_RUN) as run:
            orch.ensure_built()
        run.assert_not_called()

    def test_is_running_reads_docker_ps(self) -> None:
        with patch(_RUN, return_value=_proc(stdout=ORCHESTRATOR_NAME)):
            self.assertTrue(self.orch.is_running())
        with patch(_RUN, return_value=_proc(stdout="")):
            self.assertFalse(self.orch.is_running())

    def test_stop_removes_the_container(self) -> None:
        with patch(_RUN) as run:
            self.orch.stop()
        argv = run.call_args.args[0]
        self.assertEqual(["docker", "rm", "--force", ORCHESTRATOR_NAME], argv)


if __name__ == "__main__":
    unittest.main()
