"""Regression test for asyncio InvalidStateError on talk attach.

Issue: asyncio.exceptions.InvalidStateError: invalid state was emitted during
ClientSession teardown on first talk attach.

Root cause: REPL loop was at wrong indentation, outside ClientSession context.
The while True loop and all subsequent call_tool calls were hitting a closed
session because the async with ClientSession block ended after initialize().

Fix: Commit 4bc665d moved the entire REPL loop (lines 409-655 in talk_cmd.py)
inside the ClientSession context by fixing indentation.

This test verifies the fix remains stable by:
1. Checking code structure (indentation test)
2. Testing production InvalidStateError suppression handler
3. Testing cleanup patterns with real runtime wiring
"""

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

import pytest
from rich.console import Console
from returns.result import Success

from anima.app.launch_service import LaunchResult
from anima.core.schemas import AgentStateData
from tests.fixtures.execution_contract_resolution import (
    make_consumer_conformance_fixture,
)


def _console() -> tuple[Console, object]:
    """Create console with captured output."""
    import io

    stream = io.StringIO()
    return Console(file=stream, force_terminal=True, width=200), stream


def test_talk_cmd_repl_inside_client_session():
    """Verify REPL loop is inside ClientSession context manager.

    Regression test for commit 4bc665d which fixed indentation.
    The main while True loop (not the one inside nested async def) must be
    inside the async with ClientSession block.
    """
    talk_runtime_path = (
        Path(__file__).parent.parent
        / "src"
        / "anima"
        / "shell"
        / "cli_commands"
        / "talk_runtime.py"
    )

    with open(talk_runtime_path) as f:
        lines = f.readlines()

    # Find the async with ClientSession block (now extracted to talk_runtime.py)
    found_async_with_session = False
    session_indent = None
    line_idx = 0

    for idx, line in enumerate(lines):
        if "async with ClientSession(" in line and "as session:" in line:
            found_async_with_session = True
            session_indent = len(line) - len(line.lstrip())
            line_idx = idx
            break

    assert found_async_with_session, "async with ClientSession block not found"
    assert session_indent is not None, "Session indent not found"

    # Find the MAIN processing loop (after stdin_task creation).
    # The code structure is:
    #   async with ClientSession(...) as session:
    #       ...
    #       stdin_task = ...
    #       while True:   <- THIS is the main processing loop
    found_while_loop = False
    for idx in range(line_idx + 1, min(line_idx + 200, len(lines))):
        line = lines[idx]
        stripped = line.strip()

        # After stdin_task setup, find while True
        if (
            "stdin_task = asyncio.create_task(" in stripped
            or "stdin_task = _start_stdin_reader()" in stripped
        ):
            # Next while True: is the main processing loop
            for next_idx in range(idx + 1, min(idx + 30, len(lines))):
                next_line = lines[next_idx]
                if "while True:" in next_line:
                    while_indent = len(next_line) - len(next_line.lstrip())
                    assert while_indent > session_indent, (
                        f"while True loop at line {next_idx + 1} must be inside ClientSession context "
                        f"(indent {while_indent} vs session {session_indent})"
                    )
                    found_while_loop = True
                    break
            break

    assert found_while_loop, (
        "while True REPL loop not found inside ClientSession context"
    )


def test_talk_exception_handler_suppression_from_production():
    """Verify InvalidStateError suppression handler in shared helper and wiring.

    Production-wired: verifies handler logic in talk_helpers.py and confirms
    registration in the runtime module.
    """
    base_path = (
        Path(__file__).parent.parent / "src" / "anima" / "shell" / "cli_commands"
    )
    talk_cmd_path = base_path / "talk_cmd.py"
    talk_helpers_path = base_path / "talk_helpers.py"
    talk_runtime_path = base_path / "talk_runtime.py"

    with open(talk_helpers_path) as f:
        helper_source_code = f.read()

    with open(talk_runtime_path) as f:
        runtime_source_code = f.read()

    with open(talk_cmd_path) as f:
        cmd_source_code = f.read()

    assert "from anima.shell.cli_commands.talk_helpers import" in cmd_source_code, (
        "talk_cmd must re-export suppression handler from talk_helpers"
    )
    assert "suppress_invalid_state_error" in cmd_source_code, (
        "talk_cmd compatibility shim should expose suppress_invalid_state_error"
    )

    # Verify the suppression handler exists and has correct logic in shared helper
    assert "def suppress_invalid_state_error" in helper_source_code, (
        "suppression handler function must exist in shared helper"
    )
    assert "isinstance(exception, asyncio.InvalidStateError)" in helper_source_code, (
        "suppression must check for asyncio.InvalidStateError type"
    )
    assert '"InvalidStateError" in message' in helper_source_code or (
        "'InvalidStateError' in message" in helper_source_code
    ), "suppression must also handle message-based detection for edge cases"
    assert "loop.default_exception_handler(context)" in helper_source_code, (
        "suppression must pass through non-target errors to default handler"
    )

    # Verify the handler is registered
    assert (
        "loop.set_exception_handler(deps.suppress_invalid_state_error)"
        in runtime_source_code
    ), "suppression handler must be registered with event loop"


def test_talk_exception_handler_runtime_behavior():
    """Test the exception handler behavior at runtime with real asyncio loop.

    This tests actual asyncio event loop exception handling, not a replica.
    Uses direct import instead of exec-based source parsing for robustness.
    """
    from anima.shell.cli_commands.talk_helpers import suppress_invalid_state_error

    # Capture exceptions that pass through by mocking default_exception_handler
    passed_through = []

    # Test 1: InvalidStateError is suppressed (not passed through)
    passed_through.clear()
    loop = asyncio.new_event_loop()
    loop.default_exception_handler = lambda context: passed_through.append(context)
    try:
        context = {"exception": asyncio.InvalidStateError("test"), "message": ""}
        suppress_invalid_state_error(loop, context)
        assert len(passed_through) == 0, "InvalidStateError should be suppressed"
    finally:
        loop.close()

    # Test 2: Message-based detection works
    passed_through.clear()
    loop = asyncio.new_event_loop()
    loop.default_exception_handler = lambda context: passed_through.append(context)
    try:
        context = {"exception": None, "message": "InvalidStateError occurred"}
        suppress_invalid_state_error(loop, context)
        assert len(passed_through) == 0, (
            "Message-based InvalidStateError should be suppressed"
        )

        passed_through.clear()
        context = {"exception": None, "message": "invalid state detected"}
        suppress_invalid_state_error(loop, context)
        assert len(passed_through) == 0, (
            "Message with 'invalid state' should be suppressed"
        )
    finally:
        loop.close()

    # Test 3: Other exceptions ARE passed through
    passed_through.clear()
    loop = asyncio.new_event_loop()
    loop.default_exception_handler = lambda context: passed_through.append(context)
    try:
        context = {"exception": RuntimeError("some error"), "message": ""}
        suppress_invalid_state_error(loop, context)
        assert len(passed_through) == 1, "RuntimeError should pass through"
        assert passed_through[0] == context

        passed_through.clear()
        context = {"exception": ValueError("bad value"), "message": ""}
        suppress_invalid_state_error(loop, context)
        assert len(passed_through) == 1, "ValueError should pass through"

        passed_through.clear()
        context = {"exception": ConnectionError("failed"), "message": ""}
        suppress_invalid_state_error(loop, context)
        assert len(passed_through) == 1, "ConnectionError should pass through"
    finally:
        loop.close()

    # Test 4: Non-target messages pass through
    passed_through.clear()
    loop = asyncio.new_event_loop()
    loop.default_exception_handler = lambda context: passed_through.append(context)
    try:
        context = {"exception": None, "message": "RuntimeError occurred"}
        suppress_invalid_state_error(loop, context)
        assert len(passed_through) == 1, "Non-target message should pass through"

        passed_through.clear()
        context = {"exception": None, "message": "Connection refused"}
        suppress_invalid_state_error(loop, context)
        assert len(passed_through) == 1, "Non-target message should pass through"
    finally:
        loop.close()


def test_talk_concurrent_input_completes_cleanly():
    """Verify session completes cleanly with concurrent input handling.

    Production-wired test: Uses the actual asyncio.wait pattern from
    run_talk_session to verify no InvalidStateError on clean completion.
    """
    completed_normally = False

    async def test_pattern():
        """Test the asyncio.wait pattern completes cleanly."""
        nonlocal completed_normally

        interact_done = asyncio.Event()

        async def mock_interact():
            await asyncio.sleep(0.01)
            interact_done.set()
            return MagicMock(content=[])

        async def mock_input():
            await asyncio.sleep(0.02)
            return "test message"

        interact_task = asyncio.create_task(mock_interact())
        input_task = asyncio.create_task(mock_input())

        done, pending = await asyncio.wait(
            [interact_task, input_task],
            return_when=asyncio.FIRST_COMPLETED,
        )

        for task in pending:
            task.cancel()
            try:
                await task
            except asyncio.CancelledError:
                pass

        assert interact_task.done() or input_task.done()
        completed_normally = True

    asyncio.run(test_pattern())

    assert completed_normally, "Concurrent input pattern should complete cleanly"


def test_talk_session_cleanup_no_orphaned_futures():
    """Verify session cleanup doesn't leave orphaned stdin reader tasks."""

    async def test_pattern() -> None:
        """Drive the cleanup path for a pending stdin reader task."""

        from anima.shell.cli_commands import talk_runtime

        async def _hang_read(_prompt_session) -> str:
            await asyncio.sleep(10.0)
            return "never used"

        input_queue: asyncio.Queue[str | None] = asyncio.Queue()
        read_loop = talk_runtime.read_stdin_loop
        read_loop_params = inspect.signature(read_loop).parameters

        read_loop_kwargs = {
            "prompt_session": MagicMock(),
            "input_queue": input_queue,
        }
        if "read_input" in read_loop_params:
            read_loop_kwargs["read_input"] = _hang_read
            stdin_task = asyncio.create_task(read_loop(**read_loop_kwargs))
        elif "read_input_func" in read_loop_params:
            read_loop_kwargs["read_input_func"] = _hang_read
            stdin_task = asyncio.create_task(read_loop(**read_loop_kwargs))
        else:
            with patch.object(talk_runtime, "read_input", _hang_read, create=True):
                stdin_task = asyncio.create_task(read_loop(**read_loop_kwargs))

        await asyncio.sleep(0.01)
        assert not stdin_task.done()
        await talk_runtime.cleanup_stdin(stdin_task)

        assert stdin_task.cancelled() or stdin_task.done()

    asyncio.run(test_pattern())


@pytest.mark.parametrize(
    ("state_result", "expected_persona_name"),
    [
        pytest.param("empty", "agent", id="empty-persona-falls-back"),
        pytest.param("missing", "agent", id="missing-state-falls-back"),
        pytest.param("assistant", "assistant", id="non-empty-preserved"),
    ],
)
def test_chat_attach_persona_name_resolution_in_chat_impl(
    state_result: str, expected_persona_name: str
) -> None:
    """Verify chat attach passes resolved persona through chat_cmd chat_impl path."""
    from pathlib import Path
    from unittest.mock import AsyncMock

    from returns.result import Failure, Success

    from anima.shell.cli_commands.chat_cmd import chat_impl
    from anima.shell.state import AgentNotFoundError

    console, _ = _console()
    talk_session = AsyncMock(return_value=None)

    if state_result == "missing":
        read_state_result = Failure(AgentNotFoundError("not found"))
    else:
        mock_state = MagicMock()
        mock_state.persona_name = "" if state_result == "empty" else state_result
        read_state_result = Success(mock_state)

    with (
        patch(
            "anima.shell.cli_commands.chat_cmd._spawn_chat_agent",
            return_value="agent-123",
        ),
        patch(
            "anima.shell.cli_commands.chat_cmd._wait_for_socket_or_terminal",
            return_value=(Path("/tmp/fake.sock"), False),
        ),
        patch("pathlib.Path.exists", return_value=True),
        patch(
            "anima.shell.cli_commands.chat_cmd.read_state",
            return_value=read_state_result,
        ),
        patch("anima.shell.cli_commands.chat_cmd.run_talk_session", new=talk_session),
        patch("anima.shell.cli_commands.chat_cmd._cleanup_chat_agent"),
    ):
        with pytest.raises(SystemExit) as exit_info:
            chat_impl(
                task="hello",
                persona=None,
                system_prompt=None,
                job_file=None,
                model=None,
                llm_base_url=None,
                mcp=(),
                tools=(),
                timeout=None,
                max_turns=None,
                max_tokens=None,
                context_mode=None,
                context_bundle=None,
                instance_id=None,
                completion_policy=None,
                token_file=None,
                sandbox=False,
                sandbox_allow=(),
                keep=True,
                json_output=True,
                memory_provider=None,
                console=console,
            )

    assert exit_info.value.code == 0
    assert talk_session.await_count == 1
    assert talk_session.await_args is not None
    assert talk_session.await_args.kwargs["persona_name"] == expected_persona_name


def test_chat_spawn_path_uses_shared_launch_service(tmp_path: Path) -> None:
    """Chat spawn delegates launch resolution through app launch service."""
    from anima.shell.cli_commands.chat_cmd import _spawn_chat_agent

    persona_file = tmp_path / "persona.json"
    persona_file.write_text(
        json.dumps(
            {
                "id": "chat-fixture",
                "description": "chat fixture",
                "prompt": "You are chat fixture",
                "model": "gpt-4o-mini",
            }
        )
    )

    contract_fixture = make_consumer_conformance_fixture(instance_id="inst_chat_launch")
    launch_result = LaunchResult(
        instance_id="inst_chat_launch",
        trace_id="tr_chat_launch",
        resolved_contract=contract_fixture.contract,
        initial_state=AgentStateData(
            instance_id="inst_chat_launch",
            trace_id="tr_chat_launch",
            persona_name="chat-fixture",
            persona_ref=str(persona_file),
            model=contract_fixture.contract.runtime.effective_model,
        ),
    )

    class _FakeProcess:
        pid = 43210

    console, _ = _console()
    with (
        patch(
            "anima.shell.cli_commands.run_spawn.DefaultLaunchService.launch",
            return_value=Success(launch_result),
        ) as launch_mock,
        patch(
            "anima.shell.cli_commands.run_spawn._launch_spawn_subprocess_or_exit",
            return_value=_FakeProcess(),
        ),
        patch(
            "anima.shell.cli_commands.run_spawn.write_agent_pid",
            return_value=Success(None),
        ),
        patch(
            "anima.shell.cli_commands.run_spawn.transition_state",
            return_value=Success(launch_result.initial_state),
        ),
        patch.dict("os.environ", {"ANIMA_STATE": str(tmp_path / "state")}, clear=False),
    ):
        instance_id = _spawn_chat_agent(
            task="chat launch task",
            persona=str(persona_file),
            system_prompt=None,
            job_file=None,
            model=None,
            llm_base_url=None,
            mcp=(),
            tools=(),
            timeout=None,
            max_turns=None,
            max_tokens=None,
            context_mode=None,
            context_bundle=None,
            instance_id="inst_chat_launch",
            completion_policy=None,
            token_file=None,
            sandbox=False,
            sandbox_allow=(),
            memory_provider=None,
            json_output=False,
            console=console,
        )

    assert instance_id == "inst_chat_launch"
    assert launch_mock.call_count == 1


if __name__ == "__main__":
    sys.exit(pytest.main([__file__, "-v"]))
