"""Component seam tests re-scoped from E2E CLI/crash suites.

These tests intentionally patch boundary seams (`AgentRunner`, `subprocess`,
persona loading, kill signals) and therefore live in component scope.

Companion real-boundary coverage remains in:
- tests/test_e2e_cli.py
- tests/test_e2e_crash_recovery.py

Canonical MCP policy live-path proof remains in:
- tests/test_integration_mcp_tool_policy_runtime.py
with wrapper/classification support in:
- tests/test_unit_mcp_toolset_policy.py
"""

import json
import signal
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from click.testing import CliRunner
from returns.result import Success

from anima.core.schemas import (
    AgentResult,
    AgentState,
    AgentStateData,
    JobSpec,
    PersonaSpec,
    TokenUsage,
)
from anima.shell.cli import main
from anima.shell.state import create_agent_state, read_state, write_result, write_state


@pytest.fixture
def cli_runner() -> CliRunner:
    return CliRunner()


@pytest.fixture
def persona_file(tmp_path: Path) -> Path:
    persona = {
        "name": "component-persona",
        "prompt": "You are a component seam test assistant.",
        "model": "component-model",
        "spec_version": "0.1.0",
    }
    file_path = tmp_path / "persona.json"
    file_path.write_text(json.dumps(persona))
    return file_path


class TestComponentRunAndSpawnSeams:
    def test_run_creates_state_and_completes(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.agent.AgentRunner") as mock_runner_class:
            mock_runner = MagicMock()
            mock_runner.run = AsyncMock(
                return_value=Success(
                    AgentResult(
                        final_state=AgentState.completed,
                        token_usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
                        result_ref="results/test.json",
                    )
                )
            )
            mock_runner_class.return_value = mock_runner

            result = cli_runner.invoke(
                main,
                ["run", "test task", "--persona", str(persona_file), "--no-rm"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
                catch_exceptions=False,
            )

            assert result.exit_code == 0, f"Output: {result.output}"
            agents_dir = tmp_state_dir / "agents"
            agent_dirs = list(agents_dir.iterdir())
            assert len(agent_dirs) == 1

    def test_run_json_output_has_required_keys(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.agent.AgentRunner") as mock_runner_class:
            mock_runner = MagicMock()
            mock_runner.run = AsyncMock(
                return_value=Success(
                    AgentResult(
                        final_state=AgentState.completed,
                        token_usage=TokenUsage(prompt_tokens=50, completion_tokens=25),
                    )
                )
            )
            mock_runner_class.return_value = mock_runner

            result = cli_runner.invoke(
                main,
                ["run", "test task", "--persona", str(persona_file), "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
                catch_exceptions=False,
            )

            assert result.exit_code == 0
            output = json.loads(result.output.strip())
            assert "instance_id" in output
            assert "final_state" in output
            assert "persona" in output

    def test_run_model_override(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.agent.AgentRunner") as mock_runner_class:
            mock_runner = MagicMock()
            mock_runner.run = AsyncMock(
                return_value=Success(
                    AgentResult(
                        final_state=AgentState.completed, token_usage=TokenUsage()
                    )
                )
            )
            mock_runner_class.return_value = mock_runner

            result = cli_runner.invoke(
                main,
                [
                    "run",
                    "test task",
                    "--persona",
                    str(persona_file),
                    "--model",
                    "override-model",
                ],
                env={"ANIMA_STATE": str(tmp_state_dir)},
                catch_exceptions=False,
            )
            assert result.exit_code == 0

    def test_run_response_text_display(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.agent.AgentRunner") as mock_runner_class:
            mock_runner = MagicMock()
            mock_runner.run = AsyncMock(
                return_value=Success(
                    AgentResult(
                        final_state=AgentState.completed,
                        token_usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
                        response_text="Hello world",
                    )
                )
            )
            mock_runner_class.return_value = mock_runner

            result = cli_runner.invoke(
                main,
                ["run", "test task", "--persona", str(persona_file)],
                env={"ANIMA_STATE": str(tmp_state_dir)},
                catch_exceptions=False,
            )
            assert result.exit_code == 0
            assert "Hello world" in result.output

    def test_run_failed_state_exits_nonzero(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.agent.AgentRunner") as mock_runner_class:
            mock_runner = MagicMock()
            mock_runner.run = AsyncMock(
                return_value=Success(
                    AgentResult(
                        final_state=AgentState.failed,
                        token_usage=TokenUsage(prompt_tokens=10, completion_tokens=5),
                        error="Test failure",
                    )
                )
            )
            mock_runner_class.return_value = mock_runner

            result = cli_runner.invoke(
                main,
                ["run", "failing task", "--persona", str(persona_file), "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
                catch_exceptions=False,
            )
            assert result.exit_code == 1

    def test_spawn_creates_background_agent(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
            mock_popen.return_value = MagicMock(pid=12345)
            with patch("anima.shell.cli.load_persona") as mock_load:
                mock_load.return_value = PersonaSpec(
                    id="e2e-persona",
                    description="A test persona.",
                    prompt="Test",
                    model="test-model",
                )
                result = cli_runner.invoke(
                    main,
                    ["spawn", "background task", "--persona", str(persona_file)],
                    env={"ANIMA_STATE": str(tmp_state_dir)},
                    catch_exceptions=False,
                )
                assert result.exit_code == 0

    def test_spawn_status_roundtrip_json(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
            mock_popen.return_value = MagicMock(pid=12345)
            with patch(
                "anima.shell.cli_commands.run_spawn.load_persona_or_synthesize"
            ) as mock_load:
                mock_load.return_value = PersonaSpec(
                    id="roundtrip-persona",
                    description="A test persona.",
                    prompt="Test",
                    model="test-model",
                )
                spawn_result = cli_runner.invoke(
                    main,
                    [
                        "spawn",
                        "roundtrip task",
                        "--persona",
                        str(persona_file),
                        "--json",
                    ],
                    env={"ANIMA_STATE": str(tmp_state_dir)},
                    catch_exceptions=False,
                )
                assert spawn_result.exit_code == 0
                captured_instance_id = json.loads(spawn_result.output.strip())[
                    "instance_id"
                ]

        status_result = cli_runner.invoke(
            main,
            ["status", captured_instance_id, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert status_result.exit_code == 0

    def test_spawn_status_roundtrip_prefix(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
            mock_popen.return_value = MagicMock(pid=54321)
            with patch(
                "anima.shell.cli_commands.run_spawn.load_persona_or_synthesize"
            ) as mock_load:
                mock_load.return_value = PersonaSpec(
                    id="prefix-persona",
                    description="A test persona.",
                    prompt="Test",
                    model="test-model",
                )
                spawn_result = cli_runner.invoke(
                    main,
                    [
                        "spawn",
                        "prefix task",
                        "--persona",
                        str(persona_file),
                        "--json",
                    ],
                    env={"ANIMA_STATE": str(tmp_state_dir)},
                    catch_exceptions=False,
                )
                captured_instance_id = json.loads(spawn_result.output.strip())[
                    "instance_id"
                ]

        prefix = captured_instance_id[:12]
        status_result = cli_runner.invoke(
            main,
            ["status", prefix, "--json"],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert status_result.exit_code == 0

    def test_spawn_status_roundtrip_human_output(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
            mock_popen.return_value = MagicMock(pid=98765)
            with patch(
                "anima.shell.cli_commands.run_spawn.load_persona_or_synthesize"
            ) as mock_load:
                mock_load.return_value = PersonaSpec(
                    id="human-output-persona",
                    description="A test persona.",
                    prompt="Test",
                    model="test-model",
                )
                spawn_result = cli_runner.invoke(
                    main,
                    ["spawn", "human task", "--persona", str(persona_file)],
                    env={"ANIMA_STATE": str(tmp_state_dir)},
                    catch_exceptions=False,
                )
                assert spawn_result.exit_code == 0
                assert "Instance ID" in spawn_result.output


class TestComponentKillAndFallbackSeams:
    def test_kill_terminates_agent(
        self, cli_runner: CliRunner, tmp_state_dir: Path
    ) -> None:
        job = JobSpec(persona_ref="test@test", instance_id="inst_kill")
        agent = create_agent_state("inst_kill", "kill-persona", job).unwrap()

        now = datetime.now(timezone.utc).isoformat()
        running_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.running,
                "pid": 12345,
                "updated_at": now,
            }
        )
        write_state(agent.instance_id, running_state, "start", {})

        def _kill_side_effect(pid: int, sig: int) -> None:
            if sig == 0:
                raise ProcessLookupError("process gone")

        with (
            patch(
                "anima.shell.agent_kill.os.kill", side_effect=_kill_side_effect
            ) as mock_kill,
            patch("anima.shell.agent_kill.time.sleep"),
        ):
            result = cli_runner.invoke(
                main,
                ["kill", agent.instance_id],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0
            mock_kill.assert_any_call(12345, signal.SIGTERM)

    def test_kill_json_output(self, cli_runner: CliRunner, tmp_state_dir: Path) -> None:
        job = JobSpec(persona_ref="test@test", instance_id="inst_kill_json")
        agent = create_agent_state("inst_kill_json", "kill-json-persona", job).unwrap()
        now = datetime.now(timezone.utc).isoformat()
        running_state = AgentStateData(
            **{
                **agent.model_dump(),
                "state": AgentState.running,
                "pid": 54321,
                "updated_at": now,
            }
        )
        write_state(agent.instance_id, running_state, "start", {})

        with (
            patch("anima.shell.agent_kill.os.kill"),
            patch("anima.shell.agent_kill.time.sleep"),
        ):
            result = cli_runner.invoke(
                main,
                ["kill", agent.instance_id, "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert result.exit_code == 0

    @pytest.fixture
    def isolated_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
        temp_home = tmp_path / "test_home_isolated"
        temp_home.mkdir(parents=True, exist_ok=True)
        monkeypatch.delenv("ANIMA_STATE", raising=False)
        monkeypatch.delenv("ANIMA_CONFIG", raising=False)
        monkeypatch.setattr(Path, "home", lambda: temp_home)
        return temp_home / ".anima"

    def test_run_without_anima_state_uses_default(
        self,
        cli_runner: CliRunner,
        persona_file: Path,
        isolated_home: Path,
    ) -> None:
        with patch("anima.shell.agent.AgentRunner") as mock_runner_class:
            mock_runner = MagicMock()
            mock_runner.run = AsyncMock(
                return_value=Success(
                    AgentResult(
                        final_state=AgentState.completed,
                        token_usage=TokenUsage(prompt_tokens=50, completion_tokens=25),
                    )
                )
            )
            mock_runner_class.return_value = mock_runner

            result = cli_runner.invoke(
                main,
                ["run", "test task", "--persona", str(persona_file)],
                env={},
                catch_exceptions=False,
            )
            assert result.exit_code == 0
            assert isolated_home.exists()

    def test_full_lifecycle_spawn_kill_collect(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
            mock_popen.return_value = MagicMock(pid=99999)
            with patch("anima.shell.cli.load_persona") as mock_load:
                mock_load.return_value = PersonaSpec(
                    id="lifecycle-persona",
                    description="A test persona.",
                    prompt="Test",
                    model="test-model",
                )
                spawn_result = cli_runner.invoke(
                    main,
                    [
                        "spawn",
                        "lifecycle task",
                        "--persona",
                        str(persona_file),
                        "--json",
                    ],
                    env={"ANIMA_STATE": str(tmp_state_dir)},
                    catch_exceptions=False,
                )
                assert spawn_result.exit_code == 0
                instance_id = json.loads(spawn_result.output.strip())["instance_id"]

        state_result = read_state(instance_id)
        if isinstance(state_result, Success):
            agent_state = state_result.unwrap()
            now = datetime.now(timezone.utc).isoformat()
            running_state = AgentStateData(
                **{
                    **agent_state.model_dump(),
                    "state": AgentState.running,
                    "pid": 99999,
                    "updated_at": now,
                }
            )
            write_state(instance_id, running_state, "start", {})

        with (
            patch("anima.shell.agent_kill.os.kill"),
            patch("anima.shell.agent_kill.time.sleep"),
        ):
            kill_result = cli_runner.invoke(
                main,
                ["kill", instance_id],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            assert kill_result.exit_code == 0

        collect_result = cli_runner.invoke(
            main,
            ["collect", instance_id],
            env={"ANIMA_STATE": str(tmp_state_dir)},
        )
        assert collect_result.exit_code == 0


class TestComponentCrashSpawnSeam:
    def test_spawn_then_kill_via_cli_detects_stale(
        self, cli_runner: CliRunner, tmp_state_dir: Path, persona_file: Path
    ) -> None:
        proc = subprocess.Popen(
            ["python", "-c", "import time; time.sleep(60)"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        real_pid = proc.pid

        try:
            with patch("anima.shell.cli.subprocess.Popen") as mock_popen:
                mock_popen.return_value = MagicMock(pid=real_pid)
                with patch("anima.shell.cli.load_persona") as mock_load:
                    mock_load.return_value = PersonaSpec(
                        id="crash-full-persona",
                        description="A test persona.",
                        prompt="Test",
                        model="test-model",
                    )
                    spawn_result = cli_runner.invoke(
                        main,
                        [
                            "spawn",
                            "crash test task",
                            "--persona",
                            str(persona_file),
                        ],
                        env={"ANIMA_STATE": str(tmp_state_dir)},
                        catch_exceptions=False,
                    )
                    assert spawn_result.exit_code == 0

            list_result = cli_runner.invoke(
                main,
                ["list", "--json"],
                env={"ANIMA_STATE": str(tmp_state_dir)},
            )
            agents = json.loads(list_result.output.strip())
            assert len(agents) == 1
        finally:
            if proc.poll() is None:
                proc.kill()
                proc.wait()
