"""Coverage for main.py: create and start commands."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
from typer.testing import CliRunner

from cli.config import LocalBeacon, LocalState
from cli.exceptions import BeaconAPIError, BeaconSystemError
from cli.main import app
from cli.system import NetworkInterface

from .conftest import SAMPLE_BEACON

runner = CliRunner()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _mock_api(return_data=None, side_effect=None):
    m = MagicMock()
    m.__enter__ = lambda s: m
    m.__exit__ = MagicMock(return_value=False)
    if side_effect:
        m.create.side_effect = side_effect
        m.get.side_effect = side_effect
    else:
        m.create.return_value = return_data or SAMPLE_BEACON
        m.get.return_value = return_data or SAMPLE_BEACON
    return m


def _fake_iface(name="eth0", cidrs=None):
    iface = NetworkInterface(name=name)
    iface.cidrs = cidrs or ["192.168.1.0/24"]
    iface.addresses = ["192.168.1.100"]
    return iface


# ---------------------------------------------------------------------------
# create - system checks
# ---------------------------------------------------------------------------


class TestCreatePrivilegeChecks:
    def test_exits_when_wireguard_not_installed(self, state_dir):
        with patch("cli.main.check_wireguard", return_value=False):
            result = runner.invoke(app, ["create"])
        assert result.exit_code == 1
        assert "WireGuard" in result.output


# ---------------------------------------------------------------------------
# create - non-interactive (all params via flags)
# ---------------------------------------------------------------------------


class TestCreateNonInteractive:
    def _run_create(self, state_dir, api_mock=None, wg_side_effect=None, rathole_pid=1234):
        api_mock = api_mock or _mock_api()
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            if wg_side_effect:
                mock_wg.up.side_effect = wg_side_effect
            else:
                mock_wg.write_config.return_value = None
                mock_wg.enable_ip_forwarding.return_value = None
                mock_wg.up.return_value = None
            mock_rat.start.return_value = rathole_pid
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "my-beacon",
                "--cidrs", "192.168.1.0/24",
                "--wg-port", "51820",
                "--skip-pentest-config",
            ])
        return result

    def test_full_success_path(self, state_dir):
        result = self._run_create(state_dir)
        assert result.exit_code == 0
        assert "running" in result.output.lower()

    def test_saves_beacon_to_local_state(self, state_dir):
        self._run_create(state_dir)
        state = LocalState.load()
        assert "beacon-id-001" in state.beacons
        b = state.beacons["beacon-id-001"]
        assert b.beacon_token == "beacon-token-abc"
        assert b.rathole_server == "1.2.3.4"
        assert b.gateway_ip == "10.252.0.1"

    def test_api_create_error_exits(self, state_dir):
        api_mock = _mock_api(side_effect=BeaconAPIError("quota exceeded", 402))
        result = self._run_create(state_dir, api_mock=api_mock)
        assert result.exit_code == 1
        assert "quota exceeded" in result.output

    def test_wireguard_up_failure_aborts(self, state_dir):
        result = self._run_create(
            state_dir, wg_side_effect=BeaconSystemError("operation not permitted")
        )
        assert result.exit_code == 1
        assert "WireGuard setup failed" in result.output

    def test_rathole_failure_rolls_back_wireguard(self, state_dir):
        api_mock = _mock_api()
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_wg.write_config.return_value = None
            mock_wg.enable_ip_forwarding.return_value = None
            mock_rat.start.side_effect = BeaconSystemError("binary missing")
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "t",
                "--cidrs", "192.168.1.0/24",
                "--skip-pentest-config",
            ])
        assert result.exit_code == 1
        assert "Tunnel setup failed" in result.output
        # WireGuard should have been rolled back
        mock_wg.down.assert_called_once()

    def test_aborts_when_name_flag_is_invalid(self, state_dir):
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]):
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "my beacon!",
                "--cidrs", "192.168.1.0/24",
            ])
        assert result.exit_code == 1
        assert "letters" in result.output.lower() or "only contain" in result.output.lower()

    def test_aborts_when_cidrs_flag_is_bare_ip(self, state_dir):
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]):
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "t",
                "--cidrs", "10.0.0.1",
            ])
        assert result.exit_code == 1
        assert "/24" in result.output  # hint about missing prefix

    def test_aborts_when_no_cidrs_and_none_detected(self, state_dir):
        """With --yes and no --cidrs, abort with a clear message pointing to env var."""
        with patch("cli.main.check_wireguard", return_value=True):
            result = runner.invoke(app, [
                "create", "--yes", "--name", "t",
            ])
        assert result.exit_code == 1
        assert "ETHIACK_BEACON_CIDRS" in result.output

    def test_create_does_not_pass_dns_to_create_endpoint(self, state_dir):
        """DNS is auto-configured via api.update() after create; create endpoint receives no dns_server."""
        api_mock = _mock_api()
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 111
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "t", "--cidrs", "192.168.1.0/24",
                "--skip-pentest-config",
            ])
        assert result.exit_code == 0
        # create() should NOT include dns_server (DNS auto-set via update())
        call_kwargs = api_mock.create.call_args.kwargs
        assert call_kwargs.get("dns_server") is None
        # update() should have been called to set DNS to the gateway IP
        api_mock.update.assert_called_once()
        update_kwargs = api_mock.update.call_args.kwargs
        assert "dns_server" in update_kwargs


# ---------------------------------------------------------------------------
# create - pentest slug / CIDR scope flow
# ---------------------------------------------------------------------------


class TestCreateWithPentestSlug:
    def _run_create_with_pentest(self, state_dir, pentest_slug="my-pentest", api_mock=None):
        if api_mock is None:
            api_mock = _mock_api()
        api_mock.create_asset.return_value = {"id": 42}
        api_mock.add_asset_to_event.return_value = {}
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.write_config.return_value = None
            mock_wg.enable_ip_forwarding.return_value = None
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "my-beacon",
                "--cidrs", "192.168.1.0/24",
                "--pentest-slug", pentest_slug,
            ])
        return result, api_mock

    def test_create_asset_called_per_cidr(self, state_dir):
        result, api_mock = self._run_create_with_pentest(state_dir)
        assert result.exit_code == 0
        api_mock.create_asset.assert_called_once()

    def test_create_asset_called_with_correct_args(self, state_dir):
        result, api_mock = self._run_create_with_pentest(state_dir)
        assert result.exit_code == 0
        call_kwargs = api_mock.create_asset.call_args.kwargs
        assert call_kwargs["asset_value"] == "192.168.1.0/24"
        assert call_kwargs["asset_type"] == "cidr"

    def test_add_asset_to_event_called_with_slug_and_id(self, state_dir):
        result, api_mock = self._run_create_with_pentest(state_dir)
        assert result.exit_code == 0
        api_mock.add_asset_to_event.assert_called_once()
        call_kwargs = api_mock.add_asset_to_event.call_args.kwargs
        assert call_kwargs["pentest_slug"] == "my-pentest"
        assert call_kwargs["asset_id"] == 42

    def test_no_pentest_slug_skips_cidr_flow(self, state_dir):
        api_mock = _mock_api()
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "my-beacon",
                "--cidrs", "192.168.1.0/24",
                "--skip-pentest-config",
            ])
        assert result.exit_code == 0
        api_mock.create_asset.assert_not_called()

    def test_409_on_create_asset_shows_warning_and_continues(self, state_dir):
        api_mock = _mock_api()
        api_mock.create_asset.side_effect = BeaconAPIError("Asset already exists", 409)
        result, _ = self._run_create_with_pentest(state_dir, api_mock=api_mock)
        assert result.exit_code == 0
        assert "already exists" in result.output or "skipping" in result.output

    def test_skip_pentest_flag_suppresses_pentest_flow(self, state_dir):
        """--skip-pentest-config prevents pentest flow even when slug is provided."""
        api_mock = _mock_api()
        api_mock.create_asset.return_value = {"id": 42}
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "my-beacon",
                "--cidrs", "192.168.1.0/24",
                "--pentest-slug", "my-pentest",
                "--skip-pentest-config",
            ])
        assert result.exit_code == 0
        api_mock.create_asset.assert_not_called()

    def test_skip_pentest_env_var_suppresses_pentest_flow(self, state_dir, monkeypatch):
        """ETHIACK_SKIP_PENTEST_CONFIG env var has same effect as --skip-pentest-config."""
        monkeypatch.setenv("ETHIACK_SKIP_PENTEST_CONFIG", "1")
        api_mock = _mock_api()
        api_mock.create_asset.return_value = {"id": 42}
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "my-beacon",
                "--cidrs", "192.168.1.0/24",
                "--pentest-slug", "my-pentest",
            ])
        assert result.exit_code == 0
        api_mock.create_asset.assert_not_called()


# ---------------------------------------------------------------------------
# create - ETHIACK_ASSUME_DETECTED_CIDRS
# ---------------------------------------------------------------------------


class TestCreateAssumeDetectedCidrs:
    def _run_create_assume(self, state_dir, interfaces=None, monkeypatch=None, extra_args=None):
        api_mock = _mock_api()
        ifaces = interfaces if interfaces is not None else [_fake_iface("eth0", ["10.0.0.0/8"])]
        args = ["create", "--yes", "--name", "my-beacon", "--assume-detected-cidrs", "--skip-pentest-config"]
        if extra_args:
            args += extra_args
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=ifaces), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, args)
        return result, api_mock

    def test_auto_detects_and_creates_without_cidrs_flag(self, state_dir):
        result, api_mock = self._run_create_assume(state_dir)
        assert result.exit_code == 0
        call_kwargs = api_mock.create.call_args.kwargs
        assert call_kwargs["allowed_cidrs"] == ["10.0.0.0/8"]

    def test_uses_all_interface_cidrs(self, state_dir):
        ifaces = [
            _fake_iface("eth0", ["10.0.0.0/8"]),
            _fake_iface("eth1", ["192.168.1.0/24"]),
        ]
        result, api_mock = self._run_create_assume(state_dir, interfaces=ifaces)
        assert result.exit_code == 0
        call_kwargs = api_mock.create.call_args.kwargs
        assert set(call_kwargs["allowed_cidrs"]) == {"10.0.0.0/8", "192.168.1.0/24"}

    def test_deduplicates_cidrs_across_interfaces(self, state_dir):
        ifaces = [
            _fake_iface("eth0", ["10.0.0.0/8"]),
            _fake_iface("eth1", ["10.0.0.0/8"]),
        ]
        result, api_mock = self._run_create_assume(state_dir, interfaces=ifaces)
        assert result.exit_code == 0
        call_kwargs = api_mock.create.call_args.kwargs
        assert call_kwargs["allowed_cidrs"] == ["10.0.0.0/8"]

    def test_aborts_when_no_interfaces_detected(self, state_dir):
        result, _ = self._run_create_assume(state_dir, interfaces=[])
        assert result.exit_code == 1
        assert "ETHIACK_ASSUME_DETECTED_CIDRS" in result.output

    def test_env_var_works_without_flag(self, state_dir, monkeypatch):
        monkeypatch.setenv("ETHIACK_ASSUME_DETECTED_CIDRS", "1")
        api_mock = _mock_api()
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface("eth0", ["172.16.0.0/12"])]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, ["create", "--yes", "--name", "my-beacon", "--skip-pentest-config"])
        assert result.exit_code == 0
        call_kwargs = api_mock.create.call_args.kwargs
        assert call_kwargs["allowed_cidrs"] == ["172.16.0.0/12"]

    def test_cidrs_flag_takes_precedence_over_assume(self, state_dir):
        """When --cidrs is explicitly set, ETHIACK_ASSUME_DETECTED_CIDRS is ignored."""
        ifaces = [_fake_iface("eth0", ["10.0.0.0/8"])]
        result, api_mock = self._run_create_assume(
            state_dir, interfaces=ifaces,
            extra_args=["--cidrs", "192.168.99.0/24"],
        )
        assert result.exit_code == 0
        call_kwargs = api_mock.create.call_args.kwargs
        assert call_kwargs["allowed_cidrs"] == ["192.168.99.0/24"]


# ---------------------------------------------------------------------------
# create - --create-pentest
# ---------------------------------------------------------------------------


class TestCreateWithNewPentest:
    ORG_ID = "test-org-id"
    ORG_NAME = "Acme Corp"

    def _run_create_with_new_pentest(self, state_dir, extra_args=None, api_mock=None):
        if api_mock is None:
            api_mock = _mock_api()
        api_mock.get_organizations.return_value = [{"id": self.ORG_ID, "name": self.ORG_NAME}]
        api_mock.create_pentest.return_value = {"id": "acme-corp-my-beacon", "success": True}
        api_mock.create_asset.return_value = {"id": 42}
        api_mock.add_asset_to_event.return_value = {}
        api_mock.start_pentest.return_value = {}
        args = [
            "create", "--yes",
            "--name", "my-beacon",
            "--cidrs", "192.168.1.0/24",
            "--create-pentest",
        ]
        if extra_args:
            args += extra_args
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.write_config.return_value = None
            mock_wg.enable_ip_forwarding.return_value = None
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, args)
        return result, api_mock

    def test_create_pentest_called_with_org_and_name(self, state_dir):
        result, api_mock = self._run_create_with_new_pentest(state_dir)
        assert result.exit_code == 0
        api_mock.create_pentest.assert_called_once()
        call_kwargs = api_mock.create_pentest.call_args.kwargs
        assert call_kwargs["organization_id"] == self.ORG_ID
        assert call_kwargs["name"] == f"{self.ORG_NAME} - my-beacon"
        assert call_kwargs["add_assets"] is False

    def test_cidrs_added_to_new_pentest(self, state_dir):
        result, api_mock = self._run_create_with_new_pentest(state_dir)
        assert result.exit_code == 0
        api_mock.create_asset.assert_called_once()
        api_mock.add_asset_to_event.assert_called_once()
        call_kwargs = api_mock.add_asset_to_event.call_args.kwargs
        assert call_kwargs["pentest_slug"] == "acme-corp-my-beacon"
        assert call_kwargs["asset_id"] == 42

    def test_skip_pentest_config_suppresses_create_pentest(self, state_dir):
        api_mock = _mock_api()
        api_mock.get_organizations.return_value = [{"id": self.ORG_ID, "name": self.ORG_NAME}]
        api_mock.create_pentest.return_value = {"id": "slug"}
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "my-beacon",
                "--cidrs", "192.168.1.0/24",
                "--create-pentest",
                "--skip-pentest-config",
            ])
        assert result.exit_code == 0
        api_mock.create_pentest.assert_not_called()

    def test_create_pentest_api_failure_shows_warning_and_continues(self, state_dir):
        api_mock = _mock_api()
        api_mock.get_organizations.return_value = [{"id": self.ORG_ID, "name": self.ORG_NAME}]
        api_mock.create_pentest.side_effect = BeaconAPIError("internal error", 500)
        result, _ = self._run_create_with_new_pentest(state_dir, api_mock=api_mock)
        # Beacon creation itself should still succeed
        assert result.exit_code == 0
        assert "Failed to create pentest" in result.output

    def test_env_var_triggers_create_pentest(self, state_dir, monkeypatch):
        monkeypatch.setenv("ETHIACK_CREATE_PENTEST", "true")
        api_mock = _mock_api()
        api_mock.get_organizations.return_value = [{"id": self.ORG_ID, "name": self.ORG_NAME}]
        api_mock.create_pentest.return_value = {"id": "acme-corp-my-beacon"}
        api_mock.create_asset.return_value = {"id": 42}
        api_mock.add_asset_to_event.return_value = {}
        api_mock.start_pentest.return_value = {}
        with patch("cli.main.check_wireguard", return_value=True), \
             patch("cli.main.get_interfaces", return_value=[_fake_iface()]), \
             patch("cli.main.find_free_udp_port", return_value=51820), \
             patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 1234
            mock_rat.RATHOLE_DIR = state_dir / "rathole"
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, [
                "create", "--yes",
                "--name", "my-beacon",
                "--cidrs", "192.168.1.0/24",
            ])
        assert result.exit_code == 0
        api_mock.create_pentest.assert_called_once()


# ---------------------------------------------------------------------------
# start
# ---------------------------------------------------------------------------


class TestStart:
    def test_exits_when_incomplete_rathole_config(self, state_dir):
        api_mock = _mock_api(return_data={**SAMPLE_BEACON, "rathole_server": None})
        with patch("cli.main._api", return_value=api_mock):
            result = runner.invoke(app, ["start", "beacon-id-001"])
        assert result.exit_code == 1
        assert "Incomplete rathole config" in result.output

    def test_success_without_prior_local_state(self, state_dir):
        api_mock = _mock_api()
        with patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 5678
            result = runner.invoke(app, ["start", "beacon-id-001"])
        assert result.exit_code == 0
        # New local state entry should be created with runtime credentials
        state = LocalState.load()
        assert "beacon-id-001" in state.beacons
        b = state.beacons["beacon-id-001"]
        assert b.beacon_token == "beacon-token-abc"
        assert b.rathole_server == "1.2.3.4"

    def test_success_updates_existing_local_state(self, state_dir, populated_state):
        # populated_state has beacon_token/rathole_server → local-state path, no API call
        with patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat, \
             patch("cli.main.dns_mgr") as mock_dns:
            mock_wg.up.return_value = None
            mock_rat.start.return_value = 7777
            mock_dns.is_installed.return_value = False
            result = runner.invoke(app, ["start", "beacon-id-001"])
        assert result.exit_code == 0
        state = LocalState.load()
        assert state.beacons["beacon-id-001"].rathole_pid == 7777

    def test_wireguard_failure_aborts(self, state_dir):
        api_mock = _mock_api()
        with patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr"):
            mock_wg.up.side_effect = BeaconSystemError("permission denied")
            result = runner.invoke(app, ["start", "beacon-id-001"])
        assert result.exit_code == 1

    def test_rathole_failure_rolls_back_wireguard(self, state_dir):
        api_mock = _mock_api()
        with patch("cli.main._api", return_value=api_mock), \
             patch("cli.main.wg") as mock_wg, \
             patch("cli.main.rathole_mgr") as mock_rat:
            mock_wg.up.return_value = None
            mock_rat.start.side_effect = BeaconSystemError("port in use")
            result = runner.invoke(app, ["start", "beacon-id-001"])
        assert result.exit_code == 1
        mock_wg.down.assert_called_once()

    def test_api_error_exits(self, state_dir):
        api_mock = _mock_api(side_effect=BeaconAPIError("not found", 404))
        with patch("cli.main._api", return_value=api_mock):
            result = runner.invoke(app, ["start", "beacon-id-001"])
        assert result.exit_code == 1
