"""Pytest fixtures for anima tests.

Provides reusable fixtures for:
- Temporary state directory ($ANIMA_STATE)
- Sample PersonaSpec and JobSpec data
- Sample agent state
- Mock LLM responses
- Fake MCP server for testing

Hermetic Testing (DX-77):
- HYPOTHESIS_STORAGE_DIRECTORY: Isolated hypothesis cache
- All test artifacts isolated from repo state
"""

import asyncio
import io
import json
import os
import types
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch

import pytest
from rich.console import Console

from anima.core.schemas import (
    AgentState,
    AgentStateData,
    TokenUsage,
)
from tests.helpers.mcp_fakes import FakeMCPServer, MCPServerProtocol  # noqa: F401


# =============================================================================
# Hermetic Test Configuration (DX-77)
# =============================================================================


def pytest_configure(config: pytest.Config) -> None:
    """Configure isolated test environment before plugin initialization.

    Must run before hypothesis plugin loads to redirect storage to temp dir.

    Sets environment variables to isolate test artifacts from repo state:
    - HYPOTHESIS_STORAGE_DIRECTORY: Isolated hypothesis cache
    - HF_HOME, HF_DATASETS_CACHE, XDG_CACHE_HOME: Isolated HuggingFace cache

    This ensures .hypothesis/ and .lattice/ artifacts are created in temp
    directories, not the repo root.

    Args:
        config: Pytest configuration object.

    >>> # Auto-hook, runs before test session starts
    >>> # Creates isolated cache directory per session
    """
    import tempfile

    # Create isolated cache directory for hypothesis
    # Must use temp dir, not tmp_path_factory (not available this early)
    cache_dir = tempfile.mkdtemp(prefix="hypothesis_cache_")
    hf_cache_dir = tempfile.mkdtemp(prefix="hf_cache_")

    # Set hypothesis to use isolated cache
    # See: https://hypothesis.readthedocs.io/en/latest/settings.html
    os.environ["HYPOTHESIS_STORAGE_DIRECTORY"] = str(cache_dir)

    # Set HuggingFace cache to isolate .lattice/ (sqlite-vec embeddings)
    # The tokenizers package creates .lattice/store.db for embedding cache
    # See: https://huggingface.co/docs/transformers/installation#cache-setup
    os.environ["HF_HOME"] = hf_cache_dir
    os.environ["HF_DATASETS_CACHE"] = hf_cache_dir
    os.environ["XDG_CACHE_HOME"] = hf_cache_dir


# =============================================================================
# Event Loop Fixture (Async Hang Prevention)
# =============================================================================


@pytest.fixture(scope="function")
def event_loop():
    """Create event loop with automatic cleanup.

    Prevents hangs from blocked executor threads:
    - Creates fresh loop per test
    - Cancels ALL tasks on cleanup
    - Waits for cancellation to complete
    - Closes loop cleanly

    This fixture is automatically applied to all async tests via pytest-asyncio.
    """
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    yield loop

    # Cleanup: cancel all tasks
    try:
        pending = asyncio.all_tasks(loop)
        for task in pending:
            task.cancel()

        # Wait for cancellation to complete
        if pending:
            loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
    finally:
        loop.close()


# =============================================================================
# Directory Fixtures
# =============================================================================


@pytest.fixture
def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
    """Create a temporary $ANIMA_STATE directory.

    Sets the ANIMA_STATE environment variable to a temp directory.
    Cleans up after the test.

    Args:
        tmp_path: Pytest's built-in temporary path fixture.
        monkeypatch: Pytest's monkeypatch fixture for env vars.

    Returns:
        Path to the temporary state directory.

    >>> # Doctest runs outside pytest context
    >>> import tempfile
    >>> with tempfile.TemporaryDirectory() as td:
    ...     state_dir = Path(td) / ".anima"
    ...     state_dir.exists() or True
    True
    """
    state_dir = tmp_path / ".anima"
    state_dir.mkdir(parents=True, exist_ok=True)

    # Set environment variable
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))

    # Create agents subdirectory
    agents_dir = state_dir / "agents"
    agents_dir.mkdir(exist_ok=True)

    yield state_dir

    # Cleanup happens automatically via tmp_path


@pytest.fixture(autouse=True)
def protect_default_anima_state(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """Prevent tests from using ~/.anima as ANIMA_STATE.

    If ANIMA_STATE is unset, this fixture assigns a per-test temporary
    directory. If set, it asserts the configured path is not ~/.anima.
    """
    configured = os.environ.get("ANIMA_STATE")
    if configured is None:
        safe_state = tmp_path / ".anima"
        safe_state.mkdir(parents=True, exist_ok=True)
        monkeypatch.setenv("ANIMA_STATE", str(safe_state))
        configured = str(safe_state)

    configured_path = Path(configured).expanduser().resolve()
    default_path = (Path.home() / ".anima").resolve()
    assert configured_path != default_path, (
        "Unsafe ANIMA_STATE for tests: configured path resolves to ~/.anima. "
        "Set ANIMA_STATE to a temporary test directory."
    )

    # Protect against global config leakage
    config_override = os.environ.get("ANIMA_CONFIG")
    if config_override is None:
        safe_config = tmp_path / "config.toml"
        # Create empty config file
        safe_config.write_text("")
        monkeypatch.setenv("ANIMA_CONFIG", str(safe_config))
    else:
        config_path = Path(config_override).expanduser().resolve()
        default_config = (Path.home() / ".anima" / "config.toml").resolve()
        assert config_path != default_config, (
            "Unsafe ANIMA_CONFIG for tests: configured path resolves to ~/.anima/config.toml. "
            "Set ANIMA_CONFIG to a temporary test directory."
        )


# =============================================================================
# PersonaSpec Fixtures
# =============================================================================


@pytest.fixture
def sample_persona_spec() -> dict[str, Any]:
    """Return a valid PersonaSpec dict matching contracts/persona_spec.schema.json.

    Provides minimal required fields with sensible defaults.

    Returns:
        A JSON-compatible dict representing a valid PersonaSpec.

    >>> spec = sample_persona_spec()
    >>> spec["id"]
    'test-persona'
    >>> spec["spec_version"]
    '0.1.0'
    >>> spec["model"]
    'test-model'
    """
    return {
        "id": "test-persona",
        "description": "A test persona for unit tests.",
        "prompt": "You are a test assistant.",
        "model": "test-model",
        "spec_version": "0.1.0",
    }


@pytest.fixture
def sample_persona_file(tmp_path: Path, sample_persona_spec: dict[str, Any]) -> Path:
    """Write sample_persona_spec to a temp JSON file.

    Args:
        tmp_path: Pytest's temporary path fixture.
        sample_persona_spec: The persona spec fixture.

    Returns:
        Path to the written JSON file.

    >>> # This is a pytest fixture, example usage:
    >>> # file_path = sample_persona_file
    >>> # content = json.loads(file_path.read_text())
    >>> # assert content["name"] == "test-persona"
    """
    file_path = tmp_path / "persona.json"
    file_path.write_text(json.dumps(sample_persona_spec, indent=2))
    return file_path


# =============================================================================
# JobSpec Fixtures
# =============================================================================


@pytest.fixture
def sample_job_spec() -> dict[str, Any]:
    """Return a valid JobSpec dict matching contracts/job_spec.schema.json.

    Provides minimal required fields with sensible defaults.

    Returns:
        A JSON-compatible dict representing a valid JobSpec.

    >>> spec = sample_job_spec()
    >>> spec["persona_ref"]
    'test-persona@test-digest'
    >>> spec["instance_id"].startswith("inst_")
    True
    >>> spec["job_version"]
    '0.1.0'
    """
    return {
        "persona_ref": "test-persona@test-digest",
        "instance_id": "inst_test123",
        "job_version": "0.1.0",
    }


@pytest.fixture
def sample_job_file(tmp_path: Path, sample_job_spec: dict[str, Any]) -> Path:
    """Write sample_job_spec to a temp JSON file.

    Args:
        tmp_path: Pytest's temporary path fixture.
        sample_job_spec: The job spec fixture.

    Returns:
        Path to the written JSON file.

    >>> # This is a pytest fixture, example usage:
    >>> # file_path = sample_job_file
    >>> # content = json.loads(file_path.read_text())
    >>> # assert content["instance_id"] == "inst_test123"
    """
    file_path = tmp_path / "job.json"
    file_path.write_text(json.dumps(sample_job_spec, indent=2))
    return file_path


# =============================================================================
# AgentState Fixtures
# =============================================================================


@pytest.fixture
def sample_agent_state() -> AgentStateData:
    """Return a valid AgentStateData with state=running.

    Provides realistic defaults for a running agent.

    Returns:
        An AgentStateData instance in running state.

    >>> state = sample_agent_state()
    >>> state.instance_id.startswith("inst_")
    True
    >>> state.agent_id == state.instance_id  # backward compat alias
    True
    >>> state.state
    <AgentState.running: 'running'>
    >>> state.lifecycle.value
    'ephemeral'
    """
    now = datetime.now(timezone.utc).isoformat()
    return AgentStateData(
        instance_id="inst_test123",
        trace_id="tr_test123",
        persona_name="test-persona",
        state=AgentState.running,
        lifecycle=AgentStateData.model_fields["lifecycle"].default,
        parent_id=None,
        children=[],
        depth=0,
        pid=12345,
        mcp_socket="/tmp/anima-test/ag_test123/mcp.sock",
        created_at=now,
        updated_at=now,
        token_usage=TokenUsage(prompt_tokens=100, completion_tokens=50),
        tools_called=[],
        current_turn=2,
        max_turns=50,
        budget_total=10000,
        grace_budget=500,
        error=None,
        result_ref=None,
    )


# =============================================================================
# LLM Mock Fixtures
# =============================================================================


@pytest.fixture
def mock_llm_response():
    """Patch PydanticAI agent to return a canned response.

    Use as a context manager or decorator to mock LLM calls.

    Returns:
        A context manager that patches the agent run method.

    Example:
        >>> # In a test:
        >>> # with mock_llm_response() as mock_run:
        >>> #     mock_run.return_value = "Test response"
        >>> #     # Run agent code here
        >>> #     assert mock_run.called
    """
    # Import at function scope to avoid import errors if pydantic_ai not installed
    try:
        from pydantic_ai import Agent

        return patch.object(Agent, "run", new_callable=AsyncMock)
    except ImportError:
        # If pydantic_ai is not available, return a generic mock
        return patch("pydantic_ai.Agent.run", new_callable=AsyncMock)


# =============================================================================
# =============================================================================
# MCP Server Protocol and Fixtures (DX-76)
# =============================================================================


@pytest.fixture
def fake_mcp_server() -> FakeMCPServer:
    """Create a FakeMCPServer instance for testing.

    Returns:
        A FakeMCPServer instance implementing MCPServerProtocol.

    Example:
        >>> # In a test:
        >>> fake_mcp = fake_mcp_server()
        >>> runner.mcp_server = fake_mcp
    """
    return FakeMCPServer()


# =============================================================================
# Talk Command Test Fixtures (gate-review-debt / talk-test-fixtures)
# =============================================================================


class FakeTextContent:
    """Minimal stand-in for mcp.types.TextContent."""

    def __init__(self, text: str) -> None:
        self.text = text


class FakeToolResult:
    """Minimal stand-in for an MCP tool call result."""

    def __init__(self, text: str) -> None:
        self.content = [FakeTextContent(text)]


class FakeStreamContext:
    """Minimal async context manager replacing streamable_http_client."""

    async def __aenter__(self) -> tuple[object, object, Any]:
        return object(), object(), lambda: "session-id"

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: Any,
    ) -> None:
        return None


def make_fake_client_session(
    tool_response: str | None = None,
) -> type:
    """Build a FakeClientSession class with an optional canned tool_response."""

    class FakeClientSession:
        def __init__(self, read_stream: object, write_stream: object) -> None:
            self.read_stream = read_stream
            self.write_stream = write_stream

        async def __aenter__(self) -> "FakeClientSession":
            return self

        async def __aexit__(
            self,
            exc_type: type[BaseException] | None,
            exc: BaseException | None,
            tb: Any,
        ) -> None:
            return None

        async def initialize(self) -> None:
            return None

        async def call_tool(self, tool: str, _params: dict[str, str]) -> FakeToolResult:
            if tool == "anima.interact" and tool_response is not None:
                return FakeToolResult(tool_response)
            raise AssertionError(f"Unexpected tool call: {tool}")

    return FakeClientSession


def _patch_mcp_modules(
    monkeypatch: pytest.MonkeyPatch,
    *,
    tool_response: str | None = None,
) -> None:
    """Inject fake MCP modules into sys.modules via monkeypatch."""
    import sys as _sys

    fake_session_cls = make_fake_client_session(tool_response)

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", fake_session_cls)

    client_module = types.ModuleType("mcp.client")

    stream_module = types.ModuleType("mcp.client.streamable_http")
    setattr(
        stream_module,
        "streamable_http_client",
        lambda _path, http_client=None: FakeStreamContext(),
    )

    mcp_types_module = types.ModuleType("mcp.types")
    setattr(mcp_types_module, "TextContent", FakeTextContent)

    monkeypatch.setitem(_sys.modules, "mcp", mcp_module)
    monkeypatch.setitem(_sys.modules, "mcp.client", client_module)
    monkeypatch.setitem(_sys.modules, "mcp.client.streamable_http", stream_module)
    monkeypatch.setitem(_sys.modules, "mcp.types", mcp_types_module)


class _McpPatcher:
    """Thin wrapper so tests can configure the tool_response per-test."""

    def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None:
        self._mp = monkeypatch

    def patch(self, *, tool_response: str | None = None) -> None:
        """Apply MCP module patches for talk runtime tests."""
        _patch_mcp_modules(self._mp, tool_response=tool_response)


@pytest.fixture()
def talk_console() -> tuple[Console, io.StringIO]:
    """Return a (Console, StringIO) pair for capturing Rich output."""
    stream = io.StringIO()
    return Console(file=stream, force_terminal=True, width=200), stream


@pytest.fixture()
def mock_mcp(monkeypatch: pytest.MonkeyPatch) -> _McpPatcher:
    """Fixture that provides a helper to patch MCP modules."""
    return _McpPatcher(monkeypatch)
