"""Real invocation-path tests for ctl tool factory.

Spec source:
- User task `coverage-tech-debt.coverage-ctl-tools` requires
  "real tool invocation path tests" for ``src/anima/shell/ctl_tools.py``.
"""

import asyncio
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock

import pytest
from returns.result import Failure, Success

from anima.core.lifecycle import LifecycleEvent
from anima.core.schemas import (
    AgentState,
    AgentStateData,
    InvalidInputError,
    PersonaSpec,
)
from anima.shell.agent import AgentRunner
from anima.shell.ctl_tools import create_ctl_tools
from anima.shell.state import read_state, transition_state, write_state


@pytest.fixture
def state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Configure ANIMA_STATE to a temporary directory."""
    root = tmp_path / "anima_state"
    root.mkdir()
    monkeypatch.setenv("ANIMA_STATE", str(root))
    return root


@pytest.fixture
def work_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Create personas directory and switch cwd for relative persona refs."""
    root = tmp_path / "work"
    personas = root / "personas"
    personas.mkdir(parents=True)

    parent = PersonaSpec(
        id="parent",
        description="A test persona.",
        prompt="Parent persona",
        model="gpt-4",
        can_spawn=True,
    )
    helper = PersonaSpec(
        id="helper",
        description="A test persona.",
        prompt="Helper persona",
        model="gpt-4",
    )
    (personas / "parent.json").write_text(parent.model_dump_json())
    (personas / "helper.json").write_text(helper.model_dump_json())

    monkeypatch.chdir(root)
    return root


def _create_running_state(instance_id: str, persona_ref: str | None = None) -> None:
    """Persist a RUNNING agent state for ctl operations."""
    state = AgentStateData(
        instance_id=instance_id,
        trace_id=f"tr_{instance_id}",
        persona_name="test",
        persona_ref=persona_ref,
        state=AgentState.running,
    )
    write_state(instance_id, state)


def _tool_map(instance_id: str, runner: AgentRunner) -> dict[str, Any]:
    """Build tool dictionary keyed by tool name."""
    tools = create_ctl_tools(instance_id, runner)
    return {getattr(tool, "name"): tool for tool in tools}


@pytest.mark.asyncio
async def test_finish_and_finish_with_error_tools_real_invocation_path(
    state_dir: Path,
) -> None:
    """Invoke ctl_finish and ctl_finish(error=...) via Tool.function end-to-end."""
    _create_running_state("inst_finish")
    _create_running_state("inst_fail")
    runner = AgentRunner(instance_id="inst_finish", task="Task")
    tools = _tool_map("inst_finish", runner)

    # Spec source: task requires real tool invocation path tests.
    finish_result = await tools["ctl_finish"].function()
    assert finish_result == "Agent finished successfully"

    finish_state_result = read_state("inst_finish")
    assert isinstance(finish_state_result, Success)
    assert finish_state_result.unwrap().state == AgentState.completed

    # ctl_finish(error=...) uses the same runner agent_id, so use a dedicated runner.
    fail_runner = AgentRunner(instance_id="inst_fail", task="Task")
    fail_tools = _tool_map("inst_fail", fail_runner)
    fail_result = await fail_tools["ctl_finish"].function(error="boom")
    assert fail_result == "Agent failed: boom"

    fail_state_result = read_state("inst_fail")
    assert isinstance(fail_state_result, Success)
    assert fail_state_result.unwrap().state == AgentState.failed


@pytest.mark.asyncio
async def test_ctl_tools_error_paths_for_missing_agent(state_dir: Path) -> None:
    """ctl_finish and ctl_finish(error=...) return formatted errors on Failure."""
    runner = AgentRunner(instance_id="inst_missing", task="Task")
    tools = _tool_map("inst_missing", runner)

    finish_result = await tools["ctl_finish"].function()
    fail_result = await tools["ctl_finish"].function(error="x")

    assert finish_result.startswith("Error:")
    assert fail_result.startswith("Error:")


@pytest.mark.asyncio
async def test_request_approval_tool_returns_decision_payload(state_dir: Path) -> None:
    """ctl_request_approval returns strict payload through tool wrapper."""
    _create_running_state("inst_approval")
    runner = AgentRunner(instance_id="inst_approval", task="Task")
    tools = _tool_map("inst_approval", runner)

    request_task = asyncio.create_task(
        tools["ctl_request_approval"].function(reason="Need approval")
    )
    await asyncio.sleep(0.2)

    transition_result = transition_state("inst_approval", LifecycleEvent.approve)
    assert isinstance(transition_result, Success)

    decision = await asyncio.wait_for(request_task, timeout=1.0)
    assert decision == {"approved": True, "note": None}


@pytest.mark.asyncio
async def test_request_approval_tool_uses_default_reason_when_omitted(
    state_dir: Path,
) -> None:
    """ctl_request_approval tolerates missing reason from tool callers."""
    _create_running_state("inst_approval_default_reason")
    runner = AgentRunner(instance_id="inst_approval_default_reason", task="Task")
    tools = _tool_map("inst_approval_default_reason", runner)

    request_task = asyncio.create_task(tools["ctl_request_approval"].function())
    await asyncio.sleep(0.2)

    transition_result = transition_state(
        "inst_approval_default_reason", LifecycleEvent.approve
    )
    assert isinstance(transition_result, Success)

    decision = await asyncio.wait_for(request_task, timeout=1.0)
    assert decision == {"approved": True, "note": None}


@pytest.mark.asyncio
async def test_request_approval_tool_formats_failure_payload(state_dir: Path) -> None:
    """ctl_request_approval maps Failure to approved=False payload."""
    runner = AgentRunner(instance_id="inst_missing", task="Task")
    tools = _tool_map("inst_missing", runner)

    decision = await tools["ctl_request_approval"].function(reason="Need approval")
    assert decision["approved"] is False
    assert isinstance(decision["note"], str)
    assert decision["note"].startswith("Error:")


@pytest.mark.asyncio
async def test_spawn_tool_real_invocation_path(
    state_dir: Path,
    work_dir: Path,
) -> None:
    """Invoke ctl_spawn via tools."""
    _create_running_state("inst_parent", persona_ref="personas/parent.json")

    spawn_runner = AgentRunner(instance_id="inst_parent", task="Task")
    spawn_tools = _tool_map("inst_parent", spawn_runner)
    spawned = await spawn_tools["ctl_spawn"].function(
        persona_ref="personas/helper.json",
        task="Help",
    )
    assert spawned.startswith("Spawned subagent: inst_")


@pytest.mark.asyncio
async def test_spawn_tool_ignores_schema_noise_additional_property(
    state_dir: Path,
    work_dir: Path,
) -> None:
    """ctl_spawn_subagent tolerates schema-noise kwargs emitted by test tooling."""
    _create_running_state("inst_parent_noise", persona_ref="personas/parent.json")

    spawn_runner = AgentRunner(instance_id="inst_parent_noise", task="Task")
    spawn_tools = _tool_map("inst_parent_noise", spawn_runner)
    spawned = await spawn_tools["ctl_spawn"].function(
        persona_ref="personas/helper.json",
        task="Help",
        additionalProperty=True,
    )

    assert spawned.startswith("Spawned subagent: inst_")


@pytest.mark.asyncio
async def test_other_ctl_tools_ignore_schema_noise_additional_property(
    state_dir: Path,
) -> None:
    """Other CTL wrappers also tolerate schema-noise kwargs emitted by tooling."""
    _create_running_state("inst_noise_finish")
    _create_running_state("inst_noise_approval")

    finish_runner = AgentRunner(instance_id="inst_noise_finish", task="Task")
    finish_tools = _tool_map("inst_noise_finish", finish_runner)
    finish_result = await finish_tools["ctl_finish"].function(additionalProperty=True)
    assert finish_result == "Agent finished successfully"

    approval_runner = AgentRunner(instance_id="inst_noise_approval", task="Task")
    approval_tools = _tool_map("inst_noise_approval", approval_runner)
    request_task = asyncio.create_task(
        approval_tools["ctl_request_approval"].function(additionalProperty=True)
    )
    await asyncio.sleep(0.2)
    transition_result = transition_state("inst_noise_approval", LifecycleEvent.approve)
    assert isinstance(transition_result, Success)
    decision = await asyncio.wait_for(request_task, timeout=1.0)
    assert decision == {"approved": True, "note": None}

    fail_runner = AgentRunner(instance_id="inst_missing_noise", task="Task")
    fail_tools = _tool_map("inst_missing_noise", fail_runner)
    fail_result = await fail_tools["ctl_finish"].function(
        error="boom", additionalProperty=True
    )
    assert fail_result.startswith("Error:")


class _FakeMCPServer:
    """Minimal stand-in for AgentMCPServer with request_persona_switch."""

    def __init__(self) -> None:
        self.last_persona: PersonaSpec | None = None
        self.last_ref: str | None = None

    def request_persona_switch(self, persona: PersonaSpec, persona_ref: str) -> bool:
        self.last_persona = persona
        self.last_ref = persona_ref
        return True


@pytest.mark.asyncio
async def test_switch_persona_file_path_still_works(
    state_dir: Path,
    work_dir: Path,
) -> None:
    """ctl_switch_persona with a valid file path bypasses larva resolution."""
    _create_running_state("inst_switch_file")
    runner = AgentRunner(instance_id="inst_switch_file", task="Task")
    fake_mcp = _FakeMCPServer()
    runner.mcp_server = fake_mcp  # type: ignore[assignment]
    tools = _tool_map("inst_switch_file", runner)

    result = await tools["ctl_switch_persona"].function(
        persona_ref="personas/helper.json"
    )
    assert "helper" in result
    assert "Will take effect" in result
    assert fake_mcp.last_persona is not None
    assert fake_mcp.last_persona.id == "helper"


@pytest.mark.asyncio
async def test_switch_persona_falls_back_to_larva_on_file_not_found(
    state_dir: Path,
    work_dir: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """ctl_switch_persona resolves spec_id via larva when file not found."""
    _create_running_state("inst_switch_larva")

    resolved_persona = PersonaSpec(
        id="code-reviewer",
        description="A code review persona.",
        prompt="Review code carefully.",
        model="gpt-4",
    )

    async def fake_resolve(spec_id: str, mcp_servers: Any) -> Success[PersonaSpec]:
        assert spec_id == "code-reviewer"
        return Success(resolved_persona)

    monkeypatch.setattr(
        "anima.shell.ctl_tools.resolve_persona_via_larva",
        fake_resolve,
        raising=False,
    )
    # The import is deferred inside the function, so patch the module directly
    import anima.shell.larva_persona_resolver as lpr_mod

    monkeypatch.setattr(lpr_mod, "resolve_persona_via_larva", fake_resolve)

    runner = AgentRunner(instance_id="inst_switch_larva", task="Task")
    fake_mcp = _FakeMCPServer()
    runner.mcp_server = fake_mcp  # type: ignore[assignment]
    runner.mcp_servers = [AsyncMock()]  # type: ignore[assignment]
    tools = _tool_map("inst_switch_larva", runner)

    result = await tools["ctl_switch_persona"].function(persona_ref="code-reviewer")
    assert "code-reviewer" in result
    assert "Will take effect" in result
    assert fake_mcp.last_persona is not None
    assert fake_mcp.last_persona.id == "code-reviewer"


@pytest.mark.asyncio
async def test_switch_persona_larva_failure_returns_error(
    state_dir: Path,
    work_dir: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """ctl_switch_persona returns error when both file and larva fail."""
    _create_running_state("inst_switch_fail")

    async def fake_resolve_fail(
        spec_id: str, mcp_servers: Any
    ) -> Failure[InvalidInputError]:
        return Failure(
            InvalidInputError(f"larva_resolve('{spec_id}') call failed: not found")
        )

    import anima.shell.larva_persona_resolver as lpr_mod

    monkeypatch.setattr(lpr_mod, "resolve_persona_via_larva", fake_resolve_fail)

    runner = AgentRunner(instance_id="inst_switch_fail", task="Task")
    fake_mcp = _FakeMCPServer()
    runner.mcp_server = fake_mcp  # type: ignore[assignment]
    runner.mcp_servers = [AsyncMock()]  # type: ignore[assignment]
    tools = _tool_map("inst_switch_fail", runner)

    result = await tools["ctl_switch_persona"].function(persona_ref="nonexistent-spec")
    assert result.startswith("Error:")
    assert fake_mcp.last_persona is None


@pytest.mark.asyncio
async def test_switch_persona_absolute_path_rejected_without_larva_fallback(
    state_dir: Path,
) -> None:
    """ctl_switch_persona rejects absolute paths without trying larva."""
    _create_running_state("inst_switch_abs")

    # Larva resolver should never be called for security errors
    larva_called = False

    async def should_not_be_called(
        spec_id: str, mcp_servers: Any
    ) -> Failure[InvalidInputError]:
        nonlocal larva_called
        larva_called = True
        return Failure(InvalidInputError("should not reach"))

    runner = AgentRunner(instance_id="inst_switch_abs", task="Task")
    runner.mcp_servers = [AsyncMock()]  # type: ignore[assignment]
    tools = _tool_map("inst_switch_abs", runner)

    result = await tools["ctl_switch_persona"].function(persona_ref="/etc/passwd")
    assert result.startswith("Error:")
    assert "Absolute paths not allowed" in result
    assert not larva_called
