from __future__ import annotations

from pathlib import Path
from unittest.mock import patch, MagicMock, call, ANY

import pytest

from claude_teams import teams, messaging
from claude_teams.models import COLOR_PALETTE, TeammateMember
from claude_teams.spawner import (
    assign_color,
    discover_harness_binary,
    kill_acpx_session,
    spawn_teammate,
)
from claude_teams.acpx_client import _PROMPT_WRAPPER


TEAM = "test-team"
SESSION_ID = "test-session-id"


@pytest.fixture
def team_dir(tmp_claude_dir: Path) -> Path:
    teams.create_team(TEAM, session_id=SESSION_ID, base_dir=tmp_claude_dir)
    return tmp_claude_dir


def _make_member(
    name: str,
    team: str = TEAM,
    color: str = "blue",
    model: str = "sonnet",
    agent_type: str = "general-purpose",
    cwd: str = "/tmp",
) -> TeammateMember:
    return TeammateMember(
        agent_id=f"{name}@{team}",
        name=name,
        agent_type=agent_type,
        model=model,
        color=color,
        joined_at=0,
        acpx_session_name=f"{name}@{team}",
        cwd=cwd,
    )


class TestAssignColor:
    def test_first_teammate_is_blue(self, team_dir: Path) -> None:
        color = assign_color(TEAM, base_dir=team_dir)
        assert color == "blue"

    def test_cycles(self, team_dir: Path) -> None:
        for i in range(len(COLOR_PALETTE)):
            member = _make_member(f"agent-{i}", color=COLOR_PALETTE[i])
            teams.add_member(TEAM, member, base_dir=team_dir)

        color = assign_color(TEAM, base_dir=team_dir)
        assert color == COLOR_PALETTE[0]


class TestSpawnTeammateNameValidation:
    def test_should_reject_empty_name(self, team_dir: Path) -> None:
        with pytest.raises(ValueError, match="Invalid"):
            spawn_teammate(
                TEAM, "", "acpx", SESSION_ID, base_dir=team_dir
            )

    def test_should_reject_name_with_special_chars(self, team_dir: Path) -> None:
        with pytest.raises(ValueError, match="Invalid"):
            spawn_teammate(
                TEAM, "agent!@#", "acpx", SESSION_ID, base_dir=team_dir
            )

    def test_should_reject_name_exceeding_64_chars(self, team_dir: Path) -> None:
        with pytest.raises(ValueError, match="too long"):
            spawn_teammate(
                TEAM, "a" * 65, "acpx", SESSION_ID, base_dir=team_dir
            )

    def test_should_reject_reserved_name_team_lead(self, team_dir: Path) -> None:
        with pytest.raises(ValueError, match="reserved"):
            spawn_teammate(
                TEAM, "team-lead", "acpx", SESSION_ID, base_dir=team_dir
            )


class TestSpawnTeammate:
    @patch("claude_teams.spawner.acpx_client")
    def test_registers_member_before_spawn(
        self, mock_acpx: MagicMock, team_dir: Path
    ) -> None:
        spawn_teammate(
            TEAM,
            "researcher",
            "acpx",
            SESSION_ID,
            base_dir=team_dir,
        )
        config = teams.read_config(TEAM, base_dir=team_dir)
        names = [m.name for m in config.members]
        assert "researcher" in names

    @patch("claude_teams.spawner.acpx_client")
    def test_ensures_inbox_exists(
        self, mock_acpx: MagicMock, team_dir: Path
    ) -> None:
        spawn_teammate(
            TEAM,
            "researcher",
            "acpx",
            SESSION_ID,
            base_dir=team_dir,
        )
        # spawn_teammate 创建 inbox 但不写入初始消息
        msgs = messaging.read_inbox(TEAM, "researcher", base_dir=team_dir)
        assert len(msgs) == 0

    @patch("claude_teams.spawner.acpx_client")
    def test_session_name_format(self, mock_acpx: MagicMock, team_dir: Path) -> None:
        member = spawn_teammate(
            TEAM,
            "researcher",
            "acpx",
            SESSION_ID,
            base_dir=team_dir,
        )
        assert member.acpx_session_name == f"researcher@{TEAM}"
        config = teams.read_config(TEAM, base_dir=team_dir)
        found = [m for m in config.members if m.name == "researcher"]
        assert found[0].acpx_session_name == f"researcher@{TEAM}"

    @patch("claude_teams.spawner.acpx_client")
    def test_calls_create_session(self, mock_acpx: MagicMock, team_dir: Path) -> None:
        spawn_teammate(
            TEAM,
            "worker",
            "/usr/local/bin/acpx",
            SESSION_ID,
            subagent_type="general-purpose",
            base_dir=team_dir,
        )
        mock_acpx.create_session.assert_called_once_with(
            "general-purpose",
            f"worker@{TEAM}",
            ANY,  # cwd
            acpx_binary="/usr/local/bin/acpx",
        )

    @patch("claude_teams.spawner.acpx_client")
    def test_does_not_call_send_prompt(
        self, mock_acpx: MagicMock, team_dir: Path
    ) -> None:
        """spawn_teammate 不再自动发送 prompt，由 send_message 工具触发"""
        spawn_teammate(
            TEAM,
            "reader",
            "/usr/local/bin/acpx",
            SESSION_ID,
            subagent_type="general-purpose",
            base_dir=team_dir,
        )
        mock_acpx.send_prompt.assert_not_called()

    @patch("claude_teams.spawner.acpx_client")
    def test_inbox_is_empty_after_spawn(
        self, mock_acpx: MagicMock, team_dir: Path
    ) -> None:
        """spawn_teammate 不再写入初始 inbox 消息"""
        spawn_teammate(
            TEAM,
            "reader",
            "acpx",
            SESSION_ID,
            base_dir=team_dir,
        )
        msgs = messaging.read_inbox(TEAM, "reader", base_dir=team_dir)
        assert len(msgs) == 0

    @patch("claude_teams.spawner.messaging")
    @patch("claude_teams.spawner.acpx_client")
    def test_rollback_member_when_ensure_inbox_fails(
        self, mock_acpx: MagicMock, mock_messaging: MagicMock, team_dir: Path
    ) -> None:
        mock_acpx.create_session.return_value = None
        mock_messaging.ensure_inbox.side_effect = OSError("disk full")

        with pytest.raises(OSError):
            spawn_teammate(
                TEAM,
                "broken-worker",
                "acpx",
                SESSION_ID,
                base_dir=team_dir,
            )

        config = teams.read_config(TEAM, base_dir=team_dir)
        names = [m.name for m in config.members]
        assert "broken-worker" not in names

    @patch("claude_teams.spawner.messaging")
    @patch("claude_teams.spawner.acpx_client")
    def test_attempts_cleanup_on_failure(
        self, mock_acpx: MagicMock, mock_messaging: MagicMock, team_dir: Path
    ) -> None:
        """失败时尝试调用 kill_acpx_session 进行清理（异步调用）"""
        mock_acpx.create_session.return_value = None
        mock_messaging.ensure_inbox.side_effect = OSError("disk full")

        with pytest.raises(OSError):
            spawn_teammate(
                TEAM,
                "broken-worker",
                "/usr/local/bin/acpx",
                SESSION_ID,
                subagent_type="general-purpose",
                base_dir=team_dir,
            )

        # 验证 member 被回滚
        config = teams.read_config(TEAM, base_dir=team_dir)
        names = [m.name for m in config.members]
        assert "broken-worker" not in names


class TestKillAcpxSession:
    @patch("claude_teams.spawner.acpx_client")
    async def test_calls_close_session(self, mock_acpx: MagicMock) -> None:
        from unittest.mock import AsyncMock
        mock_acpx.close_session = AsyncMock()
        await kill_acpx_session("claude", "worker@my-team", "/usr/local/bin/acpx", cwd="/tmp")
        mock_acpx.close_session.assert_called_once_with(
            "claude", "worker@my-team", "/tmp", acpx_binary="/usr/local/bin/acpx"
        )

    @patch("claude_teams.spawner.acpx_client")
    async def test_does_not_raise_on_failure(self, mock_acpx: MagicMock) -> None:
        from unittest.mock import AsyncMock
        from claude_teams.acpx_client import AcpxError

        mock_acpx.close_session = AsyncMock(side_effect=AcpxError("session not found"))
        # Should not raise
        await kill_acpx_session("claude", "worker@my-team", "acpx", cwd="/tmp")


class TestDiscoverHarnessBinary:
    @patch("claude_teams.spawner.shutil.which")
    def test_should_find_acpx_binary(self, mock_which: MagicMock) -> None:
        mock_which.return_value = "/usr/local/bin/acpx"
        assert discover_harness_binary("acpx") == "/usr/local/bin/acpx"
        mock_which.assert_called_once_with("acpx")

    @patch("claude_teams.spawner.shutil.which")
    def test_should_return_none_when_not_found(self, mock_which: MagicMock) -> None:
        mock_which.return_value = None
        assert discover_harness_binary("acpx") is None


class TestPromptWrapper:
    def test_wrapper_contains_name_team_and_prompt(self) -> None:
        wrapped = _PROMPT_WRAPPER.format(
            name="worker",
            team_name="my-team",
            prompt="Do the task",
        )
        assert "worker" in wrapped
        assert "my-team" in wrapped
        assert "Do the task" in wrapped


# Feature: acpx-backend-migration, Property 5: Prompt 包装完整性
from hypothesis import given, settings
from hypothesis import strategies as st

_name_strategy = st.from_regex(r"[a-zA-Z][a-zA-Z0-9_-]{0,62}", fullmatch=True)
_team_name_strategy = st.from_regex(r"[a-zA-Z][a-zA-Z0-9_-]{0,62}", fullmatch=True)
_prompt_strategy = st.text(min_size=1, max_size=500)


@given(name=_name_strategy, team_name=_team_name_strategy, prompt=_prompt_strategy)
@settings(max_examples=100)
def test_prop5_wrapped_prompt_contains_original_prompt(name: str, team_name: str, prompt: str) -> None:
    """属性 5：包装后的 prompt 必须包含原始 prompt 内容 - 验证需求 4.7"""
    wrapped = _PROMPT_WRAPPER.format(name=name, team_name=team_name, prompt=prompt)
    assert prompt in wrapped


@given(name=_name_strategy, team_name=_team_name_strategy, prompt=_prompt_strategy)
@settings(max_examples=100)
def test_prop5_wrapped_prompt_contains_name(name: str, team_name: str, prompt: str) -> None:
    """属性 5：包装后的 prompt 必须包含 name - 验证需求 4.7"""
    wrapped = _PROMPT_WRAPPER.format(name=name, team_name=team_name, prompt=prompt)
    assert name in wrapped


@given(name=_name_strategy, team_name=_team_name_strategy, prompt=_prompt_strategy)
@settings(max_examples=100)
def test_prop5_wrapped_prompt_contains_team_name(name: str, team_name: str, prompt: str) -> None:
    """属性 5：包装后的 prompt 必须包含 team_name - 验证需求 4.7"""
    wrapped = _PROMPT_WRAPPER.format(name=name, team_name=team_name, prompt=prompt)
    assert team_name in wrapped
