import json
import pytest
from unittest.mock import MagicMock, patch

from brynq import spawner

@pytest.fixture
def agents_dir(tmp_path):
    with patch("brynq.spawner.AGENTS_DIR", tmp_path / "agents"), \
         patch("brynq.spawner.CHANNELS_DIR", tmp_path / "channels"):
        (tmp_path / "agents").mkdir()
        (tmp_path / "channels").mkdir()
        yield tmp_path

@pytest.fixture
def mock_popen():
    with patch("subprocess.Popen") as mock:
        mock.return_value = MagicMock()
        mock.return_value.pid = 12345
        yield mock

@pytest.fixture
def mock_pid_alive():
    with patch("brynq.spawner._is_pid_alive", return_value=True) as mock:
        yield mock

class TestSpawnAgent:
    def test_creates_state_file(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("test-agent")
        path = agents_dir / "agents" / "test-agent.json"
        assert path.exists()
        data = json.loads(path.read_text())
        assert data["name"] == "test-agent"
        assert data["pid"] == 12345

    def test_returns_name_pid_status(self, agents_dir, mock_popen, mock_pid_alive):
        res = spawner.spawn_agent("test-agent")
        assert res == {"name": "test-agent", "pid": 12345, "status": "running"}

    def test_refuses_duplicate_name_while_running(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("test-agent")
        with pytest.raises(RuntimeError, match="already running"):
            spawner.spawn_agent("test-agent")

    def test_allows_respawn_after_dead(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("test-agent")
        mock_pid_alive.return_value = False
        # Should not raise
        res = spawner.spawn_agent("test-agent")
        assert res["status"] == "running"

    def test_sets_pythonpath_in_env(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("test-agent")
        mock_popen.assert_called_once()
        env = mock_popen.call_args[1]["env"]
        assert "PYTHONPATH" in env

    def test_passes_correct_cmd_args(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("test-agent", backend="lmstudio", model="m", channel="c")
        cmd = mock_popen.call_args[0][0]
        assert "--name" in cmd
        assert "test-agent" in cmd
        assert "--backend" in cmd
        assert "lmstudio" in cmd

class TestSpawnSwarm:
    def test_spawns_multiple_agents(self, agents_dir, mock_popen, mock_pid_alive):
        res = spawner.spawn_swarm("s1", "task", [{"name": "a1"}, {"name": "a2"}])
        assert len(res["agents"]) == 2
        assert mock_popen.call_count == 2

    def test_all_agents_on_swarm_channel(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_swarm("s1", "task", [{"name": "a1"}])
        path = agents_dir / "agents" / "a1.json"
        data = json.loads(path.read_text())
        assert data["channel"] == "swarm-s1"

    def test_posts_task_to_swarm_channel(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_swarm("s1", "my task", [{"name": "a1"}])
        path = agents_dir / "channels" / "swarm-s1.jsonl"
        assert path.exists()
        lines = path.read_text().strip().split("\n")
        assert "my task" in lines[-1]

    def test_posts_announcement_to_main_channel(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_swarm("s1", "my task", [{"name": "a1"}])
        path = agents_dir / "channels" / "dragon-synapse.jsonl"
        assert path.exists()

    def test_handles_individual_spawn_failure(self, agents_dir, mock_popen, mock_pid_alive):
        # Already running agent
        spawner.spawn_agent("a1")
        res = spawner.spawn_swarm("s1", "task", [{"name": "a1"}, {"name": "a2"}])
        assert res["agents"][0]["status"] == "failed"
        assert res["agents"][1]["status"] == "running"

class TestListAgents:
    def test_lists_running_agents(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1")
        agents = spawner.list_agents()
        assert len(agents) == 1
        assert agents[0]["name"] == "a1"
        assert agents[0]["status"] == "running"

    def test_marks_dead_agents(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1")
        mock_pid_alive.return_value = False
        agents = spawner.list_agents()
        assert len(agents) == 1
        assert agents[0]["status"] == "dead"

    def test_cleans_up_dead_agent_files(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1")
        mock_pid_alive.return_value = False
        spawner.list_agents()
        path = agents_dir / "agents" / "a1.json"
        assert not path.exists()

    def test_returns_empty_when_no_agents(self, agents_dir):
        assert spawner.list_agents() == []

    def test_includes_uptime(self, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1")
        agents = spawner.list_agents()
        assert "uptime" in agents[0]

class TestKillAgent:
    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_kills_running_agent(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1")
        res = spawner.kill_agent("a1")
        assert res is True
        mock_kill.assert_called_once_with(12345)

    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_removes_state_file(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1")
        spawner.kill_agent("a1")
        assert not (agents_dir / "agents" / "a1.json").exists()

    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_posts_termination_message(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1", channel="test")
        spawner.kill_agent("a1")
        path = agents_dir / "channels" / "test.jsonl"
        assert "terminated" in path.read_text()

    def test_returns_false_for_unknown_agent(self, agents_dir):
        assert spawner.kill_agent("a1") is False

class TestKillSwarm:
    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_kills_all_swarm_agents(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_swarm("s1", "task", [{"name": "s1-a"}, {"name": "s1-b"}])
        killed = spawner.kill_swarm("s1")
        assert killed == 2

    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_matches_by_channel_name(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("other", channel="swarm-s1")
        killed = spawner.kill_swarm("s1")
        assert killed == 1

    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_returns_kill_count(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_swarm("s1", "t", [{"name": "a"}])
        assert spawner.kill_swarm("s1") == 1

    def test_returns_zero_for_no_match(self, agents_dir):
        assert spawner.kill_swarm("s1") == 0

class TestRestartAgent:
    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_kills_and_respawns(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1")
        mock_popen.reset_mock()
        spawner.restart_agent("a1")
        mock_kill.assert_called_once_with(12345)
        mock_popen.assert_called_once()

    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_preserves_config(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1", backend="lmstudio")
        mock_popen.reset_mock()
        spawner.restart_agent("a1")
        cmd = mock_popen.call_args[0][0]
        assert "lmstudio" in cmd

    def test_raises_for_unknown_agent(self, agents_dir):
        with pytest.raises(RuntimeError):
            spawner.restart_agent("a1")

    @patch("brynq.spawner._kill_pid", return_value=True)
    def test_new_pid_differs(self, mock_kill, agents_dir, mock_popen, mock_pid_alive):
        spawner.spawn_agent("a1")
        mock_popen.return_value.pid = 999
        res = spawner.restart_agent("a1")
        assert res["pid"] == 999
