"""Tests for larva persona resolution feature.

Covers:
1. resolve_persona_via_larva (shell.larva_persona_resolver)
2. _SpawnPersonaPort larva fallback (shell.ctl_spawn)
"""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

import pytest
from returns.result import Failure, Success

from anima.core.schemas import PersonaSpec


# =============================================================================
# Shared test data
# =============================================================================

VALID_PERSONA_DICT: dict[str, Any] = {
    "id": "test",
    "description": "A test persona for unit tests",
    "prompt": "test prompt",
    "model": "test-model",
}


def _make_persona(**overrides: Any) -> PersonaSpec:
    """Build a PersonaSpec with test defaults, applying overrides."""
    fields = {**VALID_PERSONA_DICT, **overrides}
    return PersonaSpec(**fields)


# =============================================================================
# Mock MCP toolset classes
# =============================================================================


class MockToolsetTool:
    """Placeholder for a PydanticAI toolset tool."""

    pass


class MockLarvaToolset:
    """Mock MCP toolset that provides larva_resolve.

    Configure via ``_result`` (return value) or ``_exc`` (exception to raise).
    """

    def __init__(self, result: Any = None, exc: Exception | None = None) -> None:
        self._result = result
        self._exc = exc

    async def get_tools(self, ctx: Any) -> dict[str, MockToolsetTool]:
        return {"larva_resolve": MockToolsetTool()}

    async def call_tool(
        self, name: str, args: dict[str, Any], ctx: Any, tool: Any
    ) -> Any:
        if self._exc is not None:
            raise self._exc
        return self._result


class MockEmptyToolset:
    """Mock MCP toolset with no tools."""

    async def get_tools(self, ctx: Any) -> dict[str, MockToolsetTool]:
        return {}

    async def call_tool(
        self, name: str, args: dict[str, Any], ctx: Any, tool: Any
    ) -> Any:
        raise RuntimeError("No tools available")


class MockBrokenToolset:
    """Mock MCP toolset that raises on get_tools."""

    async def get_tools(self, ctx: Any) -> dict[str, MockToolsetTool]:
        raise ConnectionError("toolset unavailable")


# =============================================================================
# 1. resolve_persona_via_larva tests
# =============================================================================


class TestResolvePersonaViaLarva:
    """Tests for anima.shell.larva_persona_resolver.resolve_persona_via_larva."""

    @pytest.fixture(autouse=True)
    def _import_resolver(self) -> None:
        from anima.shell.larva_persona_resolver import resolve_persona_via_larva

        self.resolve = resolve_persona_via_larva

    def test_no_mcp_servers_returns_failure(self) -> None:
        """No MCP servers -> Failure."""
        result = asyncio.run(self.resolve("test", None))
        assert isinstance(result, Failure)
        assert "no MCP servers" in result.failure().message

    def test_empty_mcp_servers_list_returns_failure(self) -> None:
        """Empty list of MCP servers -> Failure."""
        result = asyncio.run(self.resolve("test", []))
        assert isinstance(result, Failure)
        assert "no MCP servers" in result.failure().message

    def test_mcp_servers_but_no_larva_tool_returns_failure(self) -> None:
        """MCP servers exist but none has larva_resolve -> Failure."""
        toolset = MockEmptyToolset()
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "no larva MCP server found" in result.failure().message

    def test_broken_toolset_skipped_then_failure(self) -> None:
        """Toolset that raises on get_tools is skipped; overall failure if no others."""
        toolset = MockBrokenToolset()
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "no larva MCP server found" in result.failure().message

    def test_valid_dict_returns_success(self) -> None:
        """larva_resolve returns valid PersonaSpec dict -> Success(PersonaSpec)."""
        toolset = MockLarvaToolset(result=VALID_PERSONA_DICT)
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Success)
        persona = result.unwrap()
        assert persona.id == "test"
        assert persona.prompt == "test prompt"
        assert persona.model == "test-model"

    def test_dict_with_extra_fields_filtered(self) -> None:
        """Extra fields in dict are filtered to PersonaSpec fields only."""
        data = {**VALID_PERSONA_DICT, "extra_key": "ignored", "unknown": 42}
        toolset = MockLarvaToolset(result=data)
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Success)
        persona = result.unwrap()
        assert persona.id == "test"

    def test_error_envelope_returns_failure(self) -> None:
        """larva_resolve returns error envelope -> Failure."""
        error_result = {"error": {"message": "persona not found"}}
        toolset = MockLarvaToolset(result=error_result)
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "returned error" in result.failure().message
        assert "persona not found" in result.failure().message

    def test_error_envelope_string_error(self) -> None:
        """larva_resolve returns error envelope with string error -> Failure."""
        error_result = {"error": "something went wrong"}
        toolset = MockLarvaToolset(result=error_result)
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "something went wrong" in result.failure().message

    def test_json_string_returns_success(self) -> None:
        """larva_resolve returns JSON string -> Success(PersonaSpec) after parsing."""
        toolset = MockLarvaToolset(result=json.dumps(VALID_PERSONA_DICT))
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Success)
        persona = result.unwrap()
        assert persona.id == "test"
        assert persona.model == "test-model"

    def test_invalid_json_string_returns_failure(self) -> None:
        """larva_resolve returns non-JSON string -> Failure."""
        toolset = MockLarvaToolset(result="not valid json {{{")
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "Failed to parse" in result.failure().message

    def test_mcp_content_list_with_text_attr(self) -> None:
        """larva_resolve returns MCP content list with .text attribute -> Success."""
        content_item = MagicMock()
        content_item.text = json.dumps(VALID_PERSONA_DICT)
        toolset = MockLarvaToolset(result=[content_item])
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Success)
        persona = result.unwrap()
        assert persona.id == "test"

    def test_mcp_content_list_with_dict_text(self) -> None:
        """larva_resolve returns MCP content list with dict item having text key."""
        content_item = {"text": json.dumps(VALID_PERSONA_DICT)}
        toolset = MockLarvaToolset(result=[content_item])
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Success)
        persona = result.unwrap()
        assert persona.id == "test"

    def test_mcp_content_list_invalid_json_text(self) -> None:
        """MCP content list with invalid JSON text -> Failure."""
        content_item = {"text": "not json"}
        toolset = MockLarvaToolset(result=[content_item])
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "Failed to parse" in result.failure().message

    def test_exception_during_call_returns_failure(self) -> None:
        """larva_resolve raises exception -> Failure."""
        toolset = MockLarvaToolset(exc=RuntimeError("connection lost"))
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "call failed" in result.failure().message
        assert "connection lost" in result.failure().message

    def test_unexpected_return_type_returns_failure(self) -> None:
        """larva_resolve returns unexpected type (e.g., int) -> Failure."""
        toolset = MockLarvaToolset(result=42)
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "unexpected type" in result.failure().message

    def test_invalid_persona_fields_returns_failure(self) -> None:
        """Dict missing required fields -> Failure with validation error."""
        toolset = MockLarvaToolset(result={"id": "test"})  # missing prompt, model
        result = asyncio.run(self.resolve("test", [toolset]))
        assert isinstance(result, Failure)
        assert "invalid PersonaSpec" in result.failure().message

    def test_first_toolset_with_larva_is_used(self) -> None:
        """When multiple toolsets exist, the one with larva_resolve is used."""
        empty = MockEmptyToolset()
        larva = MockLarvaToolset(result=VALID_PERSONA_DICT)
        result = asyncio.run(self.resolve("test", [empty, larva]))
        assert isinstance(result, Success)
        assert result.unwrap().id == "test"

    def test_broken_toolset_then_larva_toolset(self) -> None:
        """Broken toolset is skipped, then larva toolset is found."""
        broken = MockBrokenToolset()
        larva = MockLarvaToolset(result=VALID_PERSONA_DICT)
        result = asyncio.run(self.resolve("test", [broken, larva]))
        assert isinstance(result, Success)
        assert result.unwrap().id == "test"


# =============================================================================
# 2. _SpawnPersonaPort larva fallback tests
# =============================================================================


class TestSpawnPersonaPortLarvaFallback:
    """Tests for _SpawnPersonaPort larva fallback in ctl_spawn."""

    def test_file_exists_loads_from_file_no_larva(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """When persona file exists, loads from it; larva is not called."""
        from anima.shell.ctl_spawn import _SpawnPersonaPort

        persona_file = tmp_path / "test-agent.json"
        persona_file.write_text(json.dumps(VALID_PERSONA_DICT))

        # _load_persona_from_ref rejects absolute paths, so use relative
        monkeypatch.chdir(tmp_path)
        port = _SpawnPersonaPort(parent_model=None, mcp_servers=None)
        result = port.load_persona("test-agent.json")
        assert isinstance(result, Success)
        assert result.unwrap().id == "test"

    def test_file_not_found_no_mcp_falls_through_to_inline(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """File not found, no MCP servers -> falls through to inline synthesis."""
        from anima.shell.ctl_spawn import _SpawnPersonaPort

        # Use the inline persona name so synthesis succeeds
        from anima.shell.cli_utils import INLINE_PERSONA_NAME

        port = _SpawnPersonaPort(parent_model="test-model", mcp_servers=None)
        result = port.load_persona(INLINE_PERSONA_NAME)
        assert isinstance(result, Success)
        persona = result.unwrap()
        assert persona.id == INLINE_PERSONA_NAME
        assert persona.model == "test-model"

    def test_pre_resolved_persona_returned_directly(self) -> None:
        """When pre_resolved is set, load_persona returns it without file I/O."""
        from anima.shell.ctl_spawn import _SpawnPersonaPort

        pre_resolved = PersonaSpec(**VALID_PERSONA_DICT)
        port = _SpawnPersonaPort(parent_model=None, pre_resolved=pre_resolved)
        result = port.load_persona("anything-ignored")
        assert isinstance(result, Success)
        persona = result.unwrap()
        assert persona.id == "test"
        assert persona.model == "test-model"

    def test_file_not_found_no_pre_resolved_falls_through(self) -> None:
        """File not found, no pre_resolved -> falls through to inline synthesis."""
        from anima.shell.ctl_spawn import _SpawnPersonaPort

        # Use inline persona name so inline synthesis works
        from anima.shell.cli_utils import INLINE_PERSONA_NAME

        port = _SpawnPersonaPort(
            parent_model="fallback-model",
        )
        result = port.load_persona(INLINE_PERSONA_NAME)
        assert isinstance(result, Success)
        persona = result.unwrap()
        assert persona.id == INLINE_PERSONA_NAME
        assert persona.model == "fallback-model"

    def test_absolute_path_rejected_no_fallback(self) -> None:
        """Absolute paths are security-rejected, no larva fallback attempted."""
        from anima.shell.ctl_spawn import _SpawnPersonaPort

        toolset = MockLarvaToolset(result=VALID_PERSONA_DICT)
        port = _SpawnPersonaPort(parent_model=None, mcp_servers=[toolset])
        result = port.load_persona("/etc/passwd")
        assert isinstance(result, Failure)
        assert "Absolute paths not allowed" in result.failure().message

    def test_path_traversal_rejected_no_fallback(self) -> None:
        """Path traversal is security-rejected, no larva fallback attempted."""
        from anima.shell.ctl_spawn import _SpawnPersonaPort

        toolset = MockLarvaToolset(result=VALID_PERSONA_DICT)
        port = _SpawnPersonaPort(parent_model=None, mcp_servers=[toolset])
        result = port.load_persona("../secrets.json")
        assert isinstance(result, Failure)
        assert "Path traversal not allowed" in result.failure().message
