"""Tests for ctl_delegate.py: ctl_delegate, ctl_await, and helpers.

All tests are xfail(strict=True) since implementations are stubs
(raise NotImplementedError).

Uses the same patterns as test_ctl.py:
- tmp_state_dir via ANIMA_STATE env var (from conftest.py)
- write_state / write_result for filesystem setup
- @pytest.mark.asyncio for async tests
- Success / Failure from returns.result
"""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from returns.result import Failure, Success

from anima.core.schemas import (
    AgentResult,
    AgentState,
    AgentStateData,
    AnimaError,
    InvalidInputError,
    LifecycleType,
)
from anima.shell.ctl_delegate import (
    _build_failure_envelope,
    _truncate_response,
    ctl_await,
    ctl_delegate,
)
from anima.shell.state import write_result, write_state


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_state(
    instance_id: str,
    *,
    state: AgentState = AgentState.running,
    children: list[str] | None = None,
    parent_id: str | None = None,
    current_turn: int = 0,
    error: str | None = None,
    pid: int | None = None,
    lifecycle: LifecycleType = LifecycleType.ephemeral,
) -> AgentStateData:
    """Build an AgentStateData with sensible defaults for testing."""
    return AgentStateData(
        instance_id=instance_id,
        trace_id=f"tr_{instance_id}",
        persona_name="test",
        persona_ref="personas/parent.json",
        state=state,
        lifecycle=lifecycle,
        parent_id=parent_id,
        children=children or [],
        depth=0 if parent_id is None else 1,
        max_depth=3,
        current_turn=current_turn,
        error=error,
        pid=pid,
    )


def _write_agent(
    instance_id: str,
    *,
    state: AgentState = AgentState.running,
    children: list[str] | None = None,
    parent_id: str | None = None,
    current_turn: int = 0,
    error: str | None = None,
    pid: int | None = None,
    lifecycle: LifecycleType = LifecycleType.ephemeral,
    response_text: str | None = None,
    result_final_state: AgentState | None = None,
    result_error: str | None = None,
) -> AgentStateData:
    """Write an agent's state.json (and optionally result.json) to disk."""
    state_data = _make_state(
        instance_id,
        state=state,
        children=children,
        parent_id=parent_id,
        current_turn=current_turn,
        error=error,
        pid=pid,
        lifecycle=lifecycle,
    )
    write_state(instance_id, state_data)

    if result_final_state is not None:
        result = AgentResult(
            final_state=result_final_state,
            response_text=response_text,
            error=result_error,
        )
        write_result(instance_id, result)

    return state_data


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def parent_with_children(tmp_state_dir: Path) -> dict[str, AgentStateData]:
    """Create a parent with 3 children in various states.

    Returns a dict keyed by instance_id with all 4 AgentStateData objects.
    Children are: child_a (completed), child_b (completed), child_c (failed).
    """
    child_ids = ["child_a", "child_b", "child_c"]

    parent = _write_agent(
        "inst_parent",
        state=AgentState.running,
        children=child_ids,
        pid=9999,
    )

    child_a = _write_agent(
        "child_a",
        state=AgentState.completed,
        parent_id="inst_parent",
        current_turn=3,
        result_final_state=AgentState.completed,
        response_text="Child A completed its analysis.",
    )

    child_b = _write_agent(
        "child_b",
        state=AgentState.completed,
        parent_id="inst_parent",
        current_turn=5,
        result_final_state=AgentState.completed,
        response_text="Child B finished reviewing code.",
    )

    child_c = _write_agent(
        "child_c",
        state=AgentState.failed,
        parent_id="inst_parent",
        current_turn=8,
        error="max_turns exceeded",
        result_final_state=AgentState.failed,
        response_text="Partial output before failure.",
        result_error="max_turns exceeded",
    )

    return {
        "inst_parent": parent,
        "child_a": child_a,
        "child_b": child_b,
        "child_c": child_c,
    }


# ===========================================================================
# _truncate_response tests
# ===========================================================================


class TestTruncateResponse:
    """Tests for the _truncate_response helper."""

    def test_truncate_short_text_unchanged(self) -> None:
        """Text shorter than max_chars is returned as-is."""
        text = "Hello, world!"
        result = _truncate_response(text, max_chars=100, child_id="inst_x")
        assert result == text

    def test_truncate_long_text_with_pointer(self) -> None:
        """Text longer than max_chars is truncated with result.json pointer."""
        text = "A" * 200
        result = _truncate_response(text, max_chars=50, child_id="inst_abc")
        assert len(result) <= 200  # truncated (exact length depends on suffix)
        assert "inst_abc" in result
        assert "result.json" in result
        # The first part should be the original text prefix
        assert result.startswith("A" * 30)  # at least some of the original

    def test_truncate_none_max_chars_returns_full(self) -> None:
        """max_chars=None disables truncation entirely."""
        text = "B" * 10000
        result = _truncate_response(text, max_chars=None, child_id="inst_y")
        assert result == text


# ===========================================================================
# _build_failure_envelope tests
# ===========================================================================


class TestBuildFailureEnvelope:
    """Tests for the _build_failure_envelope helper."""

    def test_failure_envelope_has_required_fields(self) -> None:
        """Verify current_turn, error, debug_hint, response_text fields."""
        state_data = _make_state(
            "inst_fail",
            state=AgentState.failed,
            current_turn=5,
            error="max_turns exceeded",
        )
        env = _build_failure_envelope("inst_fail", state_data)

        assert isinstance(env, dict)
        assert env["child_id"] == "inst_fail"
        assert env["status"] == "failed"
        assert env["current_turn"] == 5
        assert env["error"] == "max_turns exceeded"
        assert "debug_hint" in env
        assert "inst_fail" in env["debug_hint"]
        # response_text may be None for a child that never produced output
        assert "response_text" in env


# ===========================================================================
# ctl_delegate tests
# ===========================================================================


class TestCtlDelegate:
    """Tests for the ctl_delegate tool."""

    @pytest.mark.asyncio
    async def test_delegate_returns_completed_result(
        self, tmp_state_dir: Path
    ) -> None:
        """Mock spawn + terminal state + result.json, verify response_text returned."""
        # Set up parent
        _write_agent("inst_parent", state=AgentState.running, pid=1234)

        child_id = "inst_child_ok"

        # Pre-create child state + result (simulating what spawn + child execution does)
        _write_agent(
            child_id,
            state=AgentState.completed,
            parent_id="inst_parent",
            current_turn=2,
            result_final_state=AgentState.completed,
            response_text="The analysis found 3 issues.",
        )

        # Mock spawn to return the child_id
        mock_spawn_result = Success(
            json.dumps({"child_id": child_id, "status": "running"})
        )

        with (
            patch(
                "anima.shell.ctl_delegate.ctl_spawn",
                new_callable=AsyncMock,
                return_value=mock_spawn_result,
            ),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            result = await ctl_delegate("inst_parent", "Analyze the auth module")

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        assert envelope["child_id"] == child_id
        assert envelope["status"] == "completed"
        assert "3 issues" in envelope["response_text"]

    @pytest.mark.asyncio
    async def test_delegate_returns_failed_with_enriched_info(
        self, tmp_state_dir: Path
    ) -> None:
        """Child fails -- verify current_turn, error, debug_hint in return."""
        _write_agent("inst_parent", state=AgentState.running, pid=1234)

        child_id = "inst_child_fail"
        _write_agent(
            child_id,
            state=AgentState.failed,
            parent_id="inst_parent",
            current_turn=5,
            error="max_turns exceeded",
            result_final_state=AgentState.failed,
            response_text="Partial output before failure.",
            result_error="max_turns exceeded",
        )

        mock_spawn_result = Success(
            json.dumps({"child_id": child_id, "status": "running"})
        )

        with (
            patch(
                "anima.shell.ctl_delegate.ctl_spawn",
                new_callable=AsyncMock,
                return_value=mock_spawn_result,
            ),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            result = await ctl_delegate("inst_parent", "Do something")

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        assert envelope["status"] == "failed"
        assert envelope["error"] == "max_turns exceeded"
        assert envelope["current_turn"] == 5
        assert "debug_hint" in envelope
        assert child_id in envelope["debug_hint"]

    @pytest.mark.asyncio
    async def test_delegate_returns_timeout(self, tmp_state_dir: Path) -> None:
        """Timeout expires before child completes."""
        _write_agent("inst_parent", state=AgentState.running, pid=1234)

        child_id = "inst_child_slow"
        # Child stays running -- never reaches terminal
        _write_agent(
            child_id,
            state=AgentState.running,
            parent_id="inst_parent",
            current_turn=2,
            pid=5555,
        )

        mock_spawn_result = Success(
            json.dumps({"child_id": child_id, "status": "running"})
        )

        with (
            patch(
                "anima.shell.ctl_delegate.ctl_spawn",
                new_callable=AsyncMock,
                return_value=mock_spawn_result,
            ),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            result = await ctl_delegate(
                "inst_parent", "Slow task", timeout=0.001
            )

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        assert envelope["status"] == "timeout"
        assert envelope["child_id"] == child_id

    @pytest.mark.asyncio
    async def test_delegate_detects_pid_crash(self, tmp_state_dir: Path) -> None:
        """Child PID dies without terminal state -- synthetic failure returned."""
        _write_agent("inst_parent", state=AgentState.running, pid=1234)

        child_id = "inst_child_crash"
        _write_agent(
            child_id,
            state=AgentState.running,
            parent_id="inst_parent",
            current_turn=1,
            pid=99999,  # PID that does not exist
        )

        mock_spawn_result = Success(
            json.dumps({"child_id": child_id, "status": "running"})
        )

        # Mock os.kill to raise ProcessLookupError (PID dead)
        def fake_kill(pid: int, sig: int) -> None:
            raise ProcessLookupError(f"No such process: {pid}")

        with (
            patch(
                "anima.shell.ctl_delegate.ctl_spawn",
                new_callable=AsyncMock,
                return_value=mock_spawn_result,
            ),
            patch("asyncio.sleep", new_callable=AsyncMock),
            patch("os.kill", side_effect=fake_kill),
        ):
            result = await ctl_delegate("inst_parent", "Crash task")

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        assert envelope["status"] == "failed"
        assert "died" in envelope.get("error", "").lower() or "crash" in envelope.get(
            "error", ""
        ).lower()

    @pytest.mark.asyncio
    async def test_delegate_truncates_long_response(
        self, tmp_state_dir: Path
    ) -> None:
        """response_text > max_response_chars gets truncated."""
        _write_agent("inst_parent", state=AgentState.running, pid=1234)

        child_id = "inst_child_long"
        long_text = "X" * 8000
        _write_agent(
            child_id,
            state=AgentState.completed,
            parent_id="inst_parent",
            current_turn=1,
            result_final_state=AgentState.completed,
            response_text=long_text,
        )

        mock_spawn_result = Success(
            json.dumps({"child_id": child_id, "status": "running"})
        )

        with (
            patch(
                "anima.shell.ctl_delegate.ctl_spawn",
                new_callable=AsyncMock,
                return_value=mock_spawn_result,
            ),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            # Default max_response_chars is 4000
            result = await ctl_delegate("inst_parent", "Long output task")

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        # response_text should be truncated
        assert len(envelope["response_text"]) < 8000
        assert "result.json" in envelope["response_text"]

    @pytest.mark.asyncio
    async def test_delegate_rejects_daemon_lifecycle(
        self, tmp_state_dir: Path
    ) -> None:
        """ctl_delegate should force lifecycle=ephemeral, never allow daemon."""
        _write_agent(
            "inst_parent",
            state=AgentState.running,
            pid=1234,
            lifecycle=LifecycleType.daemon,
        )

        mock_spawn_result = Success(
            json.dumps({"child_id": "inst_child_d", "status": "running"})
        )

        with patch(
            "anima.shell.ctl_delegate.ctl_spawn",
            new_callable=AsyncMock,
            return_value=mock_spawn_result,
        ) as mock_spawn:
            with patch("asyncio.sleep", new_callable=AsyncMock):
                # Even if parent is daemon, delegate must spawn ephemeral
                # Pre-create a completed child to avoid infinite poll
                _write_agent(
                    "inst_child_d",
                    state=AgentState.completed,
                    parent_id="inst_parent",
                    result_final_state=AgentState.completed,
                    response_text="done",
                )
                await ctl_delegate("inst_parent", "Task for daemon guard")

            # Verify spawn was called with lifecycle=ephemeral
            call_kwargs = mock_spawn.call_args
            # The spawn call should have forced lifecycle to ephemeral
            assert call_kwargs.kwargs.get("lifecycle") != "daemon"
            assert call_kwargs.kwargs.get("lifecycle") == "ephemeral"


# ===========================================================================
# ctl_await tests
# ===========================================================================


class TestCtlAwait:
    """Tests for the ctl_await tool."""

    @pytest.mark.asyncio
    async def test_await_returns_all_results(
        self, tmp_state_dir: Path, parent_with_children: dict[str, AgentStateData]
    ) -> None:
        """3 children all terminal -- verify 3 results returned."""
        # parent_with_children has child_a (completed), child_b (completed),
        # child_c (failed) -- all terminal

        with patch("asyncio.sleep", new_callable=AsyncMock):
            result = await ctl_await(
                "inst_parent", ["child_a", "child_b", "child_c"]
            )

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        assert "results" in envelope
        assert len(envelope["results"]) == 3
        assert "summary" in envelope

        # Verify each child is represented
        child_ids_in_results = {r["child_id"] for r in envelope["results"]}
        assert child_ids_in_results == {"child_a", "child_b", "child_c"}

    @pytest.mark.asyncio
    async def test_await_returns_partial_on_timeout(
        self, tmp_state_dir: Path
    ) -> None:
        """1 of 3 completes, timeout, verify completed + pending."""
        _write_agent(
            "inst_parent",
            state=AgentState.running,
            children=["child_done", "child_slow1", "child_slow2"],
            pid=9999,
        )
        _write_agent(
            "child_done",
            state=AgentState.completed,
            parent_id="inst_parent",
            result_final_state=AgentState.completed,
            response_text="Done.",
        )
        _write_agent(
            "child_slow1",
            state=AgentState.running,
            parent_id="inst_parent",
            pid=7777,
        )
        _write_agent(
            "child_slow2",
            state=AgentState.running,
            parent_id="inst_parent",
            pid=8888,
        )

        with patch("asyncio.sleep", new_callable=AsyncMock):
            result = await ctl_await(
                "inst_parent",
                ["child_done", "child_slow1", "child_slow2"],
                timeout=0.001,
            )

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        assert len(envelope["results"]) == 3

        statuses = {r["child_id"]: r["status"] for r in envelope["results"]}
        assert statuses["child_done"] == "completed"
        assert statuses["child_slow1"] == "pending"
        assert statuses["child_slow2"] == "pending"

    @pytest.mark.asyncio
    async def test_await_rejects_invalid_child_ids(
        self, tmp_state_dir: Path
    ) -> None:
        """child_id not in parent's children list produces error."""
        _write_agent(
            "inst_parent",
            state=AgentState.running,
            children=["child_real"],
            pid=9999,
        )
        _write_agent(
            "child_real",
            state=AgentState.completed,
            parent_id="inst_parent",
            result_final_state=AgentState.completed,
            response_text="OK",
        )

        with patch("asyncio.sleep", new_callable=AsyncMock):
            result = await ctl_await(
                "inst_parent",
                ["child_real", "child_fake"],
            )

        # Should fail because child_fake is not in parent's children
        assert isinstance(result, Failure)
        error = result.failure()
        assert isinstance(error, AnimaError)

    @pytest.mark.asyncio
    async def test_await_handles_mixed_success_failure(
        self, tmp_state_dir: Path, parent_with_children: dict[str, AgentStateData]
    ) -> None:
        """2 completed, 1 failed -- verify all 3 in results."""
        with patch("asyncio.sleep", new_callable=AsyncMock):
            result = await ctl_await(
                "inst_parent", ["child_a", "child_b", "child_c"]
            )

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        results = envelope["results"]

        statuses = {r["child_id"]: r["status"] for r in results}
        assert statuses["child_a"] == "completed"
        assert statuses["child_b"] == "completed"
        assert statuses["child_c"] == "failed"

        # Failed child should have error info
        failed = next(r for r in results if r["child_id"] == "child_c")
        assert failed["error"] == "max_turns exceeded"

    @pytest.mark.asyncio
    async def test_await_truncates_each_response(
        self, tmp_state_dir: Path
    ) -> None:
        """Each child's response_text truncated per max_response_chars."""
        long_text = "Z" * 8000
        _write_agent(
            "inst_parent",
            state=AgentState.running,
            children=["child_long1", "child_long2"],
            pid=9999,
        )
        _write_agent(
            "child_long1",
            state=AgentState.completed,
            parent_id="inst_parent",
            result_final_state=AgentState.completed,
            response_text=long_text,
        )
        _write_agent(
            "child_long2",
            state=AgentState.completed,
            parent_id="inst_parent",
            result_final_state=AgentState.completed,
            response_text=long_text,
        )

        with patch("asyncio.sleep", new_callable=AsyncMock):
            result = await ctl_await(
                "inst_parent",
                ["child_long1", "child_long2"],
                max_response_chars=500,
            )

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        for child_result in envelope["results"]:
            assert len(child_result["response_text"]) < 8000
            assert "result.json" in child_result["response_text"]

    @pytest.mark.asyncio
    async def test_await_enriches_failure_returns(
        self, tmp_state_dir: Path, parent_with_children: dict[str, AgentStateData]
    ) -> None:
        """Failed child has current_turn, debug_hint in result envelope."""
        with patch("asyncio.sleep", new_callable=AsyncMock):
            result = await ctl_await(
                "inst_parent", ["child_c"]
            )

        assert isinstance(result, Success)
        envelope = json.loads(result.unwrap())
        failed = envelope["results"][0]

        assert failed["child_id"] == "child_c"
        assert failed["status"] == "failed"
        assert failed["current_turn"] == 8
        assert "debug_hint" in failed
        assert "child_c" in failed["debug_hint"]
