"""Unit: smolvm subprocess wrapper (PRD 0023 chunk 2b).

The wrapper is one thin function per smolvm CLI subcommand. Tests
mock `subprocess.run` and assert on the constructed argv +
SmolvmError raising on non-zero. The actual smolvm binary's
behavior is exercised in chunk 2d's integration smoke test."""

from __future__ import annotations

import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch

from bot_bottle.backend.smolmachines import smolvm as smolvm_mod
from bot_bottle.backend.smolmachines.smolvm import (
    SmolvmError,
    SmolvmRunResult,
    is_available,
    machine_cp,
    machine_create,
    machine_delete,
    machine_exec,
    machine_start,
    machine_stop,
    pack_create,
    pack_create_from_vm,
    wait_exec_ready,
)


def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:  # type: ignore
    return subprocess.CompletedProcess(
        args=[], returncode=0, stdout=stdout, stderr=stderr,
    )


def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:  # type: ignore
    return subprocess.CompletedProcess(
        args=[], returncode=1, stdout="", stderr=stderr,
    )


class TestArgvShapes(unittest.TestCase):
    """The CLI mixes `--name NAME` and positional-NAME styles
    across subcommands. The wrapper hides that inconsistency
    behind a uniform `name=` kwarg; lock down which form lands
    in each argv."""

    def _patch_run(self):
        return patch(
            "bot_bottle.backend.smolmachines.smolvm.subprocess.run",
            return_value=_ok(),
        )

    def test_pack_create_argv(self):
        with self._patch_run() as m:
            pack_create("bot-bottle-claude:latest", Path("/tmp/agent.smolmachine"))
        argv = m.call_args.args[0]
        self.assertEqual(
            ["smolvm", "pack", "create",
             "--image", "bot-bottle-claude:latest",
             "-o", "/tmp/agent.smolmachine"],
            argv,
        )

    def test_pack_create_from_vm_argv(self):
        with self._patch_run() as m:
            pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/committed"))
        argv = m.call_args.args[0]
        self.assertEqual(
            ["smolvm", "pack", "create",
             "--from-vm", "bot-bottle-dev-abc12",
             "-o", "/tmp/committed"],
            argv,
        )

    def test_machine_create_minimal(self):
        with self._patch_run() as m:
            machine_create("agent-xyz")
        self.assertEqual(
            ["smolvm", "machine", "create", "agent-xyz"],
            m.call_args.args[0],
        )

    def test_machine_create_with_from_and_allow_cidr_and_env(self):
        with self._patch_run() as m:
            machine_create(
                "agent-xyz",
                from_path=Path("/stage/agent.smolmachine"),
                allow_cidrs=["192.168.50.2/32"],
                env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
            )
        argv = m.call_args.args[0]
        # --from + --allow-cidr + -e are all flags, name is positional.
        self.assertEqual("smolvm", argv[0])
        self.assertIn("--from", argv)
        self.assertIn("/stage/agent.smolmachine", argv)
        # `--net` is explicit because smolvm 0.8.0's implied-net
        # from --allow-cidr doesn't fire when --from is set.
        self.assertIn("--net", argv)
        self.assertIn("--allow-cidr", argv)
        self.assertIn("192.168.50.2/32", argv)
        self.assertIn("-e", argv)
        self.assertIn("HTTPS_PROXY=http://192.168.50.2:8888", argv)
        self.assertEqual("agent-xyz", argv[-1])

    def test_machine_create_omits_net_when_no_allow_cidrs(self):
        with self._patch_run() as m:
            machine_create("agent-xyz", from_path=Path("/x.smolmachine"))
        self.assertNotIn("--net", m.call_args.args[0])

    def test_machine_start_uses_dash_name(self):
        # `start` is the --name flag form, NOT positional.
        with self._patch_run() as m:
            machine_start("agent-xyz")
        self.assertEqual(
            ["smolvm", "machine", "start", "--name", "agent-xyz"],
            m.call_args.args[0],
        )

    def test_machine_stop_uses_dash_name(self):
        with self._patch_run() as m:
            machine_stop("agent-xyz")
        self.assertEqual(
            ["smolvm", "machine", "stop", "--name", "agent-xyz"],
            m.call_args.args[0],
        )

    def test_machine_delete_uses_positional_name_and_force(self):
        # delete NAME is positional; -f required so no interactive
        # confirmation blocks teardown.
        with self._patch_run() as m:
            machine_delete("agent-xyz")
        self.assertEqual(
            ["smolvm", "machine", "delete", "-f", "agent-xyz"],
            m.call_args.args[0],
        )

    def test_machine_exec_argv_with_separator(self):
        # `--` separator before the command — smolvm's flag parser
        # would otherwise grab argv items that look like flags.
        with self._patch_run() as m:
            machine_exec("agent-xyz", ["echo", "hello"])
        self.assertEqual(
            ["smolvm", "machine", "exec", "--name", "agent-xyz",
             "--", "echo", "hello"],
            m.call_args.args[0],
        )

    def test_machine_exec_env_workdir_timeout(self):
        with self._patch_run() as m:
            machine_exec(
                "agent-xyz",
                ["ls"],
                env={"FOO": "bar", "BAZ": "qux"},
                workdir="/app",
                timeout="30s",
            )
        argv = m.call_args.args[0]
        self.assertIn("-w", argv); self.assertIn("/app", argv)
        self.assertIn("--timeout", argv); self.assertIn("30s", argv)
        # Both env vars present as -e K=V pairs.
        for pair in ("FOO=bar", "BAZ=qux"):
            self.assertIn(pair, argv)

    def test_machine_cp_positional_argv(self):
        with self._patch_run() as m:
            machine_cp("/host/file", "agent-xyz:/dest/file")
        self.assertEqual(
            ["smolvm", "machine", "cp",
             "/host/file", "agent-xyz:/dest/file"],
            m.call_args.args[0],
        )

    def test_machine_cp_empty_is_noop(self):
        # Guard against an upstream caller passing an unset path —
        # cp with an empty string is meaningless and would just
        # confuse smolvm's error message.
        with self._patch_run() as m:
            machine_cp("", "agent-xyz:/dest")
            machine_cp("/host", "")
        self.assertEqual(0, m.call_count)


class TestErrorPath(unittest.TestCase):
    """`check=True` paths raise SmolvmError on non-zero; `exec` is
    the one path that returns a result regardless."""

    def test_create_failure_raises(self):
        with patch(
            "bot_bottle.backend.smolmachines.smolvm.subprocess.run",
            return_value=_fail("no such image"),
        ):
            with self.assertRaises(SmolvmError) as cm:
                machine_create("agent-xyz")
            self.assertEqual(1, cm.exception.returncode)
            self.assertIn("no such image", str(cm.exception))

    def test_pack_create_failure_raises(self):
        with patch(
            "bot_bottle.backend.smolmachines.smolvm.subprocess.run",
            return_value=_fail("pack failed"),
        ):
            with self.assertRaises(SmolvmError):
                pack_create("missing:tag", Path("/tmp/out"))

    def test_pack_create_from_vm_failure_raises(self):
        with patch(
            "bot_bottle.backend.smolmachines.smolvm.subprocess.run",
            return_value=_fail("pack failed"),
        ):
            with self.assertRaises(SmolvmError):
                pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/out"))

    def test_exec_failure_returns_result(self):
        # The in-VM command's exit code is what Bottle.exec sees;
        # `false` exiting non-zero is not a smolvm failure.
        with patch(
            "bot_bottle.backend.smolmachines.smolvm.subprocess.run",
            return_value=subprocess.CompletedProcess(
                args=[], returncode=42, stdout="", stderr="nope",
            ),
        ):
            r = machine_exec("agent-xyz", ["sh", "-c", "exit 42"])
        self.assertEqual(SmolvmRunResult(42, "", "nope"), r)


class TestWaitExecReady(unittest.TestCase):
    """wait_exec_ready polls machine_exec(name, ["true"]) until it
    returns 0, then exits. On timeout it calls die()."""

    def test_returns_immediately_when_exec_succeeds_first_try(self):
        with patch.object(smolvm_mod, "machine_exec",
                          return_value=SmolvmRunResult(0, "", "")) as m:
            wait_exec_ready("vm-x")
        m.assert_called_once_with("vm-x", ["true"])

    def test_retries_on_nonzero_and_returns_on_success(self):
        results = [
            SmolvmRunResult(1, "", "not ready"),
            SmolvmRunResult(1, "", "not ready"),
            SmolvmRunResult(0, "", ""),
        ]
        with patch.object(smolvm_mod, "machine_exec",
                          side_effect=results) as m, \
             patch.object(smolvm_mod.time, "sleep"):
            wait_exec_ready("vm-x")
        self.assertEqual(3, m.call_count)

    def test_raises_smolvm_error_on_timeout(self):
        # machine_exec always returns non-zero; monotonic advances past
        # the deadline after the first sleep so the loop exits.
        ticks = [0.0, 0.0, 10.0]  # third call puts us past deadline
        with patch.object(smolvm_mod, "machine_exec",
                          return_value=SmolvmRunResult(1, "", "")), \
             patch.object(smolvm_mod.time, "monotonic",
                          side_effect=ticks), \
             patch.object(smolvm_mod.time, "sleep"):
            with self.assertRaises(SmolvmError) as cm:
                wait_exec_ready("vm-x", timeout=5.0)
        self.assertIn("vm-x", str(cm.exception))
        self.assertIn("not ready", str(cm.exception))


class TestIsAvailable(unittest.TestCase):
    def test_true_when_on_path(self):
        with patch(
            "bot_bottle.backend.smolmachines.smolvm.shutil.which",
            return_value="/usr/local/bin/smolvm",
        ):
            self.assertTrue(is_available())

    def test_false_when_missing(self):
        with patch(
            "bot_bottle.backend.smolmachines.smolvm.shutil.which",
            return_value=None,
        ):
            self.assertFalse(is_available())


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