"""Targeted unit coverage for monitor/serve and talk command implementations."""

from __future__ import annotations

import asyncio
import io
import json
import sys
import termios
import tty
import threading
import types
from dataclasses import replace
from pathlib import Path
from typing import Any

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

from anima.core.schemas import AgentState, AgentStateData, InternalError
from anima.shell.cli_commands import monitor_serve as monitor_serve_module
from anima.shell.cli_commands.monitor_serve import monitor_impl, serve_impl
from anima.shell.cli_commands.talk_deps import TalkDeps, default_deps
from anima.shell.cli_commands import talk_cmd as talk_cmd_module
from anima.shell.cli_commands.talk_cmd import (
    SLASH_HELP_LINES,
    talk_impl,
    validate_interactive_state,
)
from anima.shell.cli_commands.talk_runtime import run_talk_session
from anima.core.talk_commands import parse_talk_slash_command as parse_slash_command
from anima.shell.cli_commands.talk_display import print_help as render_help_text
from anima.shell.cli_commands.talk_session import (
    handle_inline_slash_command as core_handle_inline_slash_command,
)
from anima.shell.state import read_approval_response, read_state, write_state


def _agent_state(agent_id: str, state: AgentState) -> AgentStateData:
    return AgentStateData(
        instance_id=agent_id,
        persona_name="demo",
        trace_id=f"trace-{agent_id}",
        state=state,
    )


def _console() -> tuple[Console, io.StringIO]:
    stream = io.StringIO()
    return Console(file=stream, force_terminal=False), stream


def _make_input_handler(inputs_iter):
    """Create a console.input mock that raises EOFError when exhausted.

    Production code catches EOFError to exit cleanly, but StopIteration
    (raised by exhausted iterators) crashes the background stdin reader
    without signaling None to input_queue, causing hangs.
    """

    def _input(_prompt):
        try:
            return next(inputs_iter)
        except StopIteration:
            raise EOFError("end of test input")

    return _input


def _default_talk_deps_with_alive() -> TalkDeps:
    """Return talk dependencies with process liveness always reported as alive."""

    base_deps = default_deps()
    return replace(base_deps, is_agent_process_alive=lambda _agent_id: Success(True))


def _patch_legacy_handle_inline_slash_command(
    message: str,
    agent_id: str,
    json_output: bool,
    console: Console,
    deps: TalkDeps | None = None,
    parse_slash_command_func: Any | None = None,
    **_kwargs: Any,
) -> bool:
    """Wrapper accepting legacy parse callback kwargs."""

    active_deps = deps if deps is not None else _default_talk_deps_with_alive()
    if parse_slash_command_func is not None:
        active_deps = replace(
            active_deps,
            parse_slash_command=lambda line: parse_slash_command_func(line),
        )

    return core_handle_inline_slash_command(
        message=message,
        agent_id=agent_id,
        json_output=json_output,
        console=console,
        deps=active_deps,
    )


def _patch_talk_default_deps(monkeypatch: pytest.MonkeyPatch) -> None:
    """Patch talk process-liveness checks for compatibility across code variants."""

    monkeypatch.setattr(
        "anima.shell.cli_commands.talk_cmd.default_deps",
        _default_talk_deps_with_alive,
        raising=False,
    )
    monkeypatch.setattr(
        "anima.shell.cli_commands.talk_cmd.is_agent_process_alive",
        lambda _agent_id: Success(True),
        raising=False,
    )
    monkeypatch.setattr(
        talk_cmd_module,
        "handle_inline_slash_command",
        _patch_legacy_handle_inline_slash_command,
        raising=False,
    )


def test_serve_impl_json_start_failure(monkeypatch: pytest.MonkeyPatch) -> None:
    class FakeServer:
        def __init__(self, host: str, port: int) -> None:
            self.host = host
            self.port = port

        async def start(self):
            return Failure(InternalError("boom"))

        async def stop(self):
            return Success(None)

    monkeypatch.setattr("anima.shell.mcp_serve.ServeMCPServer", FakeServer)
    console, stream = _console()

    with pytest.raises(SystemExit) as exc:
        serve_impl(port=8451, host="127.0.0.1", json_output=True, console=console)

    assert exc.value.code == 1
    payload = json.loads(stream.getvalue())
    assert payload["error"]["code"] == "SERVE_START_FAILED"


def test_serve_impl_json_startup_and_shutdown(monkeypatch: pytest.MonkeyPatch) -> None:
    class ImmediateEvent:
        def set(self) -> None:
            return None

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

    class FakeServer:
        def __init__(self, host: str, port: int) -> None:
            self.host = host
            self.port = port

        async def start(self):
            return Success(None)

        async def stop(self):
            return Success(None)

    monkeypatch.setattr(asyncio, "Event", ImmediateEvent)
    monkeypatch.setattr("anima.shell.mcp_serve.ServeMCPServer", FakeServer)
    console, stream = _console()

    serve_impl(port=8452, host="127.0.0.1", json_output=True, console=console)

    payload = json.loads(stream.getvalue())
    assert payload["status"] == "listening"


def test_monitor_impl_quit_key_uses_raw_mode_and_restores(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeStdin:
        def fileno(self) -> int:
            return 77

        def isatty(self) -> bool:
            return True

    class FakeLive:
        def __init__(self, *args: object, **kwargs: object) -> None:
            return None

        def __enter__(self) -> "FakeLive":
            return self

        def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
            return None

        def update(self, _table: object) -> None:
            return None

    class InlineThread:
        def __init__(self, target: object, daemon: bool) -> None:
            self._target = target
            self.daemon = daemon

        def start(self) -> None:
            if callable(self._target):
                self._target()

    calls: list[tuple[str, object]] = []

    monkeypatch.setattr(sys, "stdin", FakeStdin())
    monkeypatch.setattr(monitor_serve_module, "Live", FakeLive)
    monkeypatch.setattr(monitor_serve_module.signal, "signal", lambda *_args: None)
    monkeypatch.setattr(monitor_serve_module.os, "read", lambda _fd, _n: b"q")

    # Patch globally to ensure tty.setcbreak/setraw use the mocked version
    monkeypatch.setattr(termios, "tcgetattr", lambda _fd: [1, 2, 3])
    monkeypatch.setattr(tty, "tcgetattr", lambda _fd: [1, 2, 3])
    monkeypatch.setattr(
        termios,
        "tcsetattr",
        lambda fd, when, attrs: calls.append(("restore", (fd, when, attrs))),
    )
    monkeypatch.setattr(tty, "tcsetattr", lambda *_: None)
    monkeypatch.setattr(
        tty,
        "setcbreak",
        lambda fd: calls.append(("setcbreak", fd)),
    )
    monkeypatch.setattr(
        tty,
        "setraw",
        lambda fd: calls.append(("setraw", fd)),
    )
    monkeypatch.setattr(monitor_serve_module.threading, "Thread", InlineThread)
    monkeypatch.setattr(monitor_serve_module, "list_agents", lambda: Success([]))

    console, stream = _console()

    with pytest.raises(SystemExit) as exc:
        monitor_impl(refresh=0.01, once=False, console=console)

    assert exc.value.code == 0
    assert calls[0] == ("setcbreak", 77)
    restore_call = calls[1]
    assert restore_call[0] == "restore"
    assert isinstance(restore_call[1], tuple)
    restore_fd, _restore_when, restore_attrs = restore_call[1]
    assert restore_fd == 77
    assert restore_attrs == [1, 2, 3]
    assert "Monitor stopped" in stream.getvalue()


def test_monitor_impl_restores_terminal_mode_on_exception(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeStdin:
        def fileno(self) -> int:
            return 41

        def isatty(self) -> bool:
            return True

    class ExplodingLive:
        def __init__(self, *args: object, **kwargs: object) -> None:
            return None

        def __enter__(self) -> "ExplodingLive":
            raise RuntimeError("boom")

        def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
            return None

    class InlineThread:
        def __init__(self, target: object, daemon: bool) -> None:
            self._target = target
            self.daemon = daemon

        def start(self) -> None:
            if callable(self._target):
                self._target()

    restored: list[tuple[int, object, object]] = []

    monkeypatch.setattr(sys, "stdin", FakeStdin())
    monkeypatch.setattr(monitor_serve_module, "Live", ExplodingLive)
    monkeypatch.setattr(monitor_serve_module.signal, "signal", lambda *_args: None)
    monkeypatch.setattr(monitor_serve_module.os, "read", lambda _fd, _n: b"q")

    # Patch globally to ensure tty.setcbreak/setraw use the mocked version
    monkeypatch.setattr(termios, "tcgetattr", lambda _fd: [4, 5, 6])
    monkeypatch.setattr(tty, "tcgetattr", lambda _fd: [4, 5, 6])
    monkeypatch.setattr(
        termios,
        "tcsetattr",
        lambda fd, when, attrs: restored.append((fd, when, attrs)),
    )
    monkeypatch.setattr(tty, "tcsetattr", lambda *_: None)
    monkeypatch.setattr(tty, "setcbreak", lambda _fd: None)
    monkeypatch.setattr(tty, "setraw", lambda _fd: None)
    monkeypatch.setattr(monitor_serve_module.threading, "Thread", InlineThread)
    monkeypatch.setattr(monitor_serve_module, "list_agents", lambda: Success([]))

    console, _stream = _console()

    with pytest.raises(RuntimeError, match="boom"):
        monitor_impl(refresh=0.01, once=False, console=console)

    assert restored == [(41, monitor_serve_module.termios.TCSADRAIN, [4, 5, 6])]


def test_monitor_impl_restores_terminal_mode_on_signal(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeStdin:
        def fileno(self) -> int:
            return 52

        def isatty(self) -> bool:
            return True

    class FakeLive:
        def __init__(self, *args: object, **kwargs: object) -> None:
            return None

        def __enter__(self) -> "FakeLive":
            return self

        def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
            return None

        def update(self, _table: object) -> None:
            return None

    class InlineThread:
        def __init__(self, target: object, daemon: bool) -> None:
            self._target = target
            self.daemon = daemon

        def start(self) -> None:
            if callable(self._target):
                self._target()

    handlers: dict[int, object] = {}
    restored: list[tuple[int, object, object]] = []

    def fake_signal(sig: int, handler: object) -> None:
        handlers[sig] = handler
        if sig == monitor_serve_module.signal.SIGTERM and callable(handler):
            handler(sig, None)

    monkeypatch.setattr(sys, "stdin", FakeStdin())
    monkeypatch.setattr(monitor_serve_module, "Live", FakeLive)
    monkeypatch.setattr(monitor_serve_module.signal, "signal", fake_signal)
    monkeypatch.setattr(monitor_serve_module.os, "read", lambda _fd, _n: b"")

    # Patch globally to ensure tty.setcbreak/setraw use the mocked version
    monkeypatch.setattr(termios, "tcgetattr", lambda _fd: [7, 8, 9])
    monkeypatch.setattr(tty, "tcgetattr", lambda _fd: [7, 8, 9])
    monkeypatch.setattr(
        termios,
        "tcsetattr",
        lambda fd, when, attrs: restored.append((fd, when, attrs)),
    )
    monkeypatch.setattr(tty, "tcsetattr", lambda *_: None)
    monkeypatch.setattr(tty, "setcbreak", lambda _fd: None)
    monkeypatch.setattr(tty, "setraw", lambda _fd: None)
    monkeypatch.setattr(monitor_serve_module.threading, "Thread", InlineThread)
    monkeypatch.setattr(monitor_serve_module, "list_agents", lambda: Success([]))

    console, _stream = _console()

    with pytest.raises(SystemExit) as exc:
        monitor_impl(refresh=0.01, once=False, console=console)

    assert exc.value.code == 0
    assert monitor_serve_module.signal.SIGINT in handlers
    assert monitor_serve_module.signal.SIGTERM in handlers
    assert restored == [(52, monitor_serve_module.termios.TCSADRAIN, [7, 8, 9])]


def test_monitor_impl_wires_sigwinch_and_invalidates_console_size(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeStdin:
        def fileno(self) -> int:
            return 61

        def isatty(self) -> bool:
            return True

    class FakeLive:
        def __init__(self, *args: object, **kwargs: object) -> None:
            return None

        def __enter__(self) -> "FakeLive":
            return self

        def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
            return None

        def update(self, _table: object) -> None:
            return None

    class InlineThread:
        def __init__(self, target: object, daemon: bool) -> None:
            self._target = target
            self.daemon = daemon

        def start(self) -> None:
            if callable(self._target):
                self._target()

    handlers: dict[int, object] = {}

    def fake_signal(sig: int, handler: object) -> None:
        handlers[sig] = handler
        if sig == monitor_serve_module.signal.SIGTERM and callable(handler):
            handler(sig, None)

    monkeypatch.setattr(sys, "stdin", FakeStdin())
    monkeypatch.setattr(monitor_serve_module, "Live", FakeLive)
    monkeypatch.setattr(monitor_serve_module.signal, "signal", fake_signal)
    monkeypatch.setattr(monitor_serve_module.os, "read", lambda _fd, _n: b"")

    # Patch globally to ensure tty.setcbreak/setraw use the mocked version
    monkeypatch.setattr(termios, "tcgetattr", lambda _fd: [10, 11, 12])
    monkeypatch.setattr(tty, "tcgetattr", lambda _fd: [10, 11, 12])
    monkeypatch.setattr(termios, "tcsetattr", lambda *_: None)
    monkeypatch.setattr(tty, "tcsetattr", lambda *_: None)
    monkeypatch.setattr(tty, "setcbreak", lambda _fd: None)
    monkeypatch.setattr(tty, "setraw", lambda _fd: None)
    monkeypatch.setattr(monitor_serve_module.threading, "Thread", InlineThread)
    monkeypatch.setattr(monitor_serve_module, "list_agents", lambda: Success([]))

    console, _stream = _console()
    setattr(console, "_size", object())  # force non-None cache sentinel

    with pytest.raises(SystemExit) as exc:
        monitor_impl(refresh=0.01, once=False, console=console)

    assert exc.value.code == 0
    assert monitor_serve_module.signal.SIGWINCH in handlers
    resize_handler = handlers[monitor_serve_module.signal.SIGWINCH]
    assert callable(resize_handler)
    resize_handler(monitor_serve_module.signal.SIGWINCH, None)
    assert getattr(console, "_size") is None


def test_monitor_impl_resize_event_triggers_live_update_path(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeStdin:
        def fileno(self) -> int:
            return 62

        def isatty(self) -> bool:
            return True

    class StopEvent:
        def __init__(self) -> None:
            self._set = False

        def set(self) -> None:
            self._set = True

        def is_set(self) -> bool:
            return self._set

    class ResizeEvent:
        def __init__(self) -> None:
            self.wait_calls: list[float] = []
            self.cleared = False
            self._first_wait = True

        def set(self) -> None:
            self._first_wait = True

        def wait(self, timeout: float) -> bool:
            self.wait_calls.append(timeout)
            if self._first_wait:
                self._first_wait = False
                return True
            return False

        def clear(self) -> None:
            self.cleared = True

    stop_event = StopEvent()
    resize_event = ResizeEvent()

    def fake_event_factory() -> object:
        if not hasattr(fake_event_factory, "calls"):
            fake_event_factory.calls = 0  # type: ignore[attr-defined]
        fake_event_factory.calls += 1  # type: ignore[attr-defined]
        if fake_event_factory.calls == 1:  # type: ignore[attr-defined]
            return stop_event
        return resize_event

    class NoStartThread:
        def __init__(self, target: object, daemon: bool) -> None:
            self._target = target
            self.daemon = daemon

        def start(self) -> None:
            return None

    class FakeLive:
        update_calls = 0

        def __init__(self, *args: object, **kwargs: object) -> None:
            return None

        def __enter__(self) -> "FakeLive":
            return self

        def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
            return None

        def update(self, _table: object) -> None:
            FakeLive.update_calls += 1
            stop_event.set()

    monkeypatch.setattr(sys, "stdin", FakeStdin())
    monkeypatch.setattr(monitor_serve_module.threading, "Event", fake_event_factory)
    monkeypatch.setattr(monitor_serve_module.threading, "Thread", NoStartThread)
    monkeypatch.setattr(monitor_serve_module, "Live", FakeLive)
    monkeypatch.setattr(monitor_serve_module.signal, "signal", lambda *_args: None)

    # Patch globally to ensure tty.setcbreak/setraw use the mocked version
    monkeypatch.setattr(termios, "tcgetattr", lambda _fd: [13, 14, 15])
    monkeypatch.setattr(tty, "tcgetattr", lambda _fd: [13, 14, 15])
    monkeypatch.setattr(termios, "tcsetattr", lambda *_: None)
    monkeypatch.setattr(tty, "tcsetattr", lambda *_: None)
    monkeypatch.setattr(tty, "setcbreak", lambda _fd: None)
    monkeypatch.setattr(tty, "setraw", lambda _fd: None)
    monkeypatch.setattr(monitor_serve_module, "list_agents", lambda: Success([]))

    console, _stream = _console()

    with pytest.raises(SystemExit) as exc:
        monitor_impl(refresh=0.25, once=False, console=console)

    assert exc.value.code == 0
    assert resize_event.wait_calls == [0.25]
    assert resize_event.cleared is True
    assert FakeLive.update_calls == 1


def test_validate_interactive_state_pending_branch() -> None:
    assert validate_interactive_state(AgentState.pending) == (
        False,
        "Agent not interactive (state: pending)",
    )


def test_talk_impl_missing_socket(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    state_dir = tmp_path / ".anima"
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    state = _agent_state("inst_real", AgentState.running)
    write_result = write_state(state.instance_id, state)
    assert write_result == Success(None)
    console, stream = _console()

    # Mock stale state detection to bypass (tests verify socket check path)
    _patch_talk_default_deps(monkeypatch)

    with pytest.raises(SystemExit) as exc:
        talk_impl("inst_real", json_output=False, console=console)

    assert exc.value.code == 1
    assert "MCP socket not found" in stream.getvalue()


def test_talk_impl_terminal_state(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    state_dir = tmp_path / ".anima"
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    state = _agent_state("inst_real", AgentState.completed)
    write_result = write_state(state.instance_id, state)
    assert write_result == Success(None)
    console, stream = _console()

    with pytest.raises(SystemExit) as exc:
        talk_impl("inst_real", json_output=False, console=console)

    assert exc.value.code == 1
    assert "terminal state" in stream.getvalue().lower()


def test_talk_impl_awaiting_approval_prints_active_prompt(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    state_dir = tmp_path / ".anima"
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    state = _agent_state("inst_real", AgentState.awaiting_approval)
    write_result = write_state(
        state.instance_id,
        state,
        "approval_requested",
        {"reason": "Need approval for tool"},
    )
    assert write_result == Success(None)

    # Compatibility with both old/new approval-reason codepaths.
    _patch_talk_default_deps(monkeypatch)
    monkeypatch.setattr(
        talk_cmd_module,
        "_core_latest_approval_reason",
        lambda agent_id, *, deps: "Need approval for tool",
        raising=False,
    )
    console, stream = _console()

    with pytest.raises(SystemExit) as exc:
        talk_impl("inst_real", json_output=False, console=console)

    assert exc.value.code == 1
    output = stream.getvalue()
    assert (
        "Agent requests approval: Need approval for tool. /approve or /reject?"
        in output
    )
    assert "Note: Agent" not in output


def test_run_talk_session_quit_immediately(monkeypatch: pytest.MonkeyPatch) -> None:
    class FakeStreamContext:
        async def __aenter__(self):
            return object(), object(), lambda: "session-id"

        async def __aexit__(self, exc_type, exc, tb):
            return None

    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):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return None

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

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", FakeClientSession)
    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(),
    )

    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)

    console, stream = _console()
    monkeypatch.setattr(console, "input", lambda _prompt: "/quit")

    # Compatibility-only: agent_id parameter is an alias for instance_id
    asyncio.run(
        run_talk_session(
            socket_path=Path("/tmp/fake.sock"),
            agent_id="inst_real",  # compatibility-only: display alias for instance_id
            json_output=False,
            console=console,
            deps=_default_talk_deps_with_alive(),
        )
    )
    assert "detaching" in stream.getvalue().lower()


def test_run_talk_session_prompts_on_awaiting_approval_transition(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    state_dir = tmp_path / ".anima"
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    initial_state = _agent_state("inst_real", AgentState.running)
    write_result = write_state(initial_state.instance_id, initial_state)
    assert write_result == Success(None)

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

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

    class FakeStreamContext:
        async def __aenter__(self):
            return object(), object(), lambda: "session-id"

        async def __aexit__(self, exc_type, exc, tb):
            return None

    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):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return None

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

        async def call_tool(
            self, _tool: str, _params: dict[str, str]
        ) -> FakeToolResult:
            transition_state = _agent_state("inst_real", AgentState.awaiting_approval)
            write_transition = write_state(
                "inst_real",
                transition_state,
                "approval_requested",
                {"reason": "Need sign-off"},
            )
            assert write_transition == Success(None)
            return FakeToolResult('{"response":"still running","turn_number":1}')

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", FakeClientSession)
    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)

    # Mock stale state detection to bypass (running agent in test)
    _patch_talk_default_deps(monkeypatch)

    console, stream = _console()
    messages = iter(["hello", "/quit"])
    monkeypatch.setattr(console, "input", _make_input_handler(messages))

    asyncio.run(
        run_talk_session(
            socket_path=Path("/tmp/fake.sock"),
            agent_id="inst_real",  # compatibility-only: display alias for instance_id
            json_output=False,
            console=console,
            deps=_default_talk_deps_with_alive(),
        )
    )
    output = stream.getvalue()
    assert "SYSTEM" in output
    assert "Need sign-off" in output
    assert "/approve" in output


def test_parse_slash_command() -> None:
    assert parse_slash_command("/approve looks good") == ("approve", "looks good")
    assert parse_slash_command("/reject") == ("reject", None)
    assert parse_slash_command("/help") == ("help", None)
    assert parse_slash_command("/stop") == ("stop", None)
    assert parse_slash_command("hello") == (None, None)


def test_handle_inline_slash_command_help() -> None:
    console, stream = _console()

    deps = replace(
        _default_talk_deps_with_alive(),
        print_help=lambda json_output, console: render_help_text(
            json_output=json_output,
            console=console,
            slash_help_lines=SLASH_HELP_LINES,
        ),
    )

    handled = core_handle_inline_slash_command(
        message="/help",
        agent_id="inst_test",  # compatibility-only: display alias for instance_id
        json_output=False,
        console=console,
        deps=deps,
    )

    assert handled is True
    output = stream.getvalue()
    assert "Available slash commands" in output
    assert "  /stop" in output
    assert "  /approve" in output
    assert "  /reject" in output


def test_run_talk_session_stop_slash_command_requests_stop_turn(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeTextContent:
        def __init__(self, text: str) -> None:
            self.text = text

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

    class FakeStreamContext:
        async def __aenter__(self):
            return object(), object(), lambda: "session-id"

        async def __aexit__(self, exc_type, exc, tb):
            return None

    class FakeClientSession:
        called_tools: list[str]

        def __init__(self, read_stream: object, write_stream: object) -> None:
            self.read_stream = read_stream
            self.write_stream = write_stream
            self.called_tools = []

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return None

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

        async def call_tool(self, tool: str, _params: dict[str, str]):
            self.called_tools.append(tool)
            if tool == "anima.stop_turn":
                return FakeToolResult('{"acknowledged": true, "scope": "current_turn"}')
            raise AssertionError(f"Unexpected tool call: {tool}")

    fake_session_ref: dict[str, FakeClientSession] = {}

    class _SessionFactory(FakeClientSession):
        def __init__(self, read_stream: object, write_stream: object) -> None:
            super().__init__(read_stream, write_stream)
            fake_session_ref["session"] = self

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", _SessionFactory)
    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)

    console, stream = _console()
    inputs = iter(["/stop", "/quit"])
    monkeypatch.setattr(console, "input", _make_input_handler(inputs))

    asyncio.run(
        run_talk_session(
            socket_path=Path("/tmp/fake.sock"),
            agent_id="inst_real",  # compatibility-only: display alias for instance_id
            json_output=False,
            console=console,
            deps=_default_talk_deps_with_alive(),
        )
    )
    session = fake_session_ref["session"]
    assert session.called_tools == ["anima.stop_turn"]
    assert "Stop requested" in stream.getvalue()


def test_run_talk_session_stop_slash_command_reports_malformed_json_response(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeTextContent:
        def __init__(self, text: str) -> None:
            self.text = text

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

    class FakeStreamContext:
        async def __aenter__(self):
            return object(), object(), lambda: "session-id"

        async def __aexit__(self, exc_type, exc, tb):
            return None

    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):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return None

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

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

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", FakeClientSession)
    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)

    console, stream = _console()
    inputs = iter(["/stop", "/quit"])
    monkeypatch.setattr(console, "input", _make_input_handler(inputs))

    asyncio.run(
        run_talk_session(
            socket_path=Path("/tmp/fake.sock"),
            agent_id="inst_real",  # compatibility-only: display alias for instance_id
            json_output=True,
            console=console,
            deps=_default_talk_deps_with_alive(),
        )
    )
    output = stream.getvalue()
    assert '"code": "INVALID_RESPONSE"' in output
    assert '"message": "Malformed stop response from agent"' in output
    assert "MCP_CONNECTION_ERROR" not in output


def test_run_talk_session_stop_waits_before_sending_follow_up(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeTextContent:
        def __init__(self, text: str) -> None:
            self.text = text

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

    stop_requested = asyncio.Event()
    follow_up_completed = threading.Event()

    class FakeStreamContext:
        async def __aenter__(self):
            return object(), object(), lambda: "session-id"

        async def __aexit__(self, exc_type, exc, tb):
            return None

    class FakeClientSession:
        def __init__(self, read_stream: object, write_stream: object) -> None:
            self.read_stream = read_stream
            self.write_stream = write_stream
            self.called_tools: list[tuple[str, dict[str, str]]] = []

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return None

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

        async def call_tool(self, tool: str, params: dict[str, str]) -> FakeToolResult:
            self.called_tools.append((tool, params))
            if tool == "anima.interact" and params == {"message": "hello"}:
                await stop_requested.wait()
                return FakeToolResult(
                    '{"response":"[Current turn cancelled by /stop]","turn_number":1}'
                )
            if tool == "anima.stop_turn":
                stop_requested.set()
                return FakeToolResult('{"acknowledged":true,"scope":"current_turn"}')
            if tool == "anima.interact" and params == {"message": "after"}:
                follow_up_completed.set()
                return FakeToolResult('{"response":"follow-up ok","turn_number":2}')
            raise AssertionError(f"Unexpected tool call: {tool} {params}")

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", FakeClientSession)
    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)

    console, stream = _console()
    scripted_inputs = iter(["hello", "/stop", "after"])

    def _input_handler(_prompt: str = "") -> str:
        try:
            return next(scripted_inputs)
        except StopIteration:
            assert follow_up_completed.wait(timeout=1.0)
            raise EOFError

    monkeypatch.setattr(console, "input", _input_handler)

    asyncio.run(
        run_talk_session(
            socket_path=Path("/tmp/fake.sock"),
            agent_id="inst_real",
            json_output=False,
            console=console,
            deps=_default_talk_deps_with_alive(),
        )
    )

    output = stream.getvalue()
    assert "follow-up ok" in output
    assert "AGENT_BUSY" not in output
    assert "thinking...You >" not in output


def test_run_talk_session_prints_busy_status_for_busy_interact(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeTextContent:
        def __init__(self, text: str) -> None:
            self.text = text

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

    class FakeStreamContext:
        async def __aenter__(self):
            return object(), object(), lambda: "session-id"

        async def __aexit__(self, exc_type, exc, tb):
            return None

    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):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            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":
                raise AssertionError(f"Unexpected tool call: {tool}")
            return FakeToolResult(
                '{"error":{"code":"AGENT_BUSY","numeric_code":10,'
                '"message":"Agent is processing another interact turn"},"turn_number":4}'
            )

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", FakeClientSession)
    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)

    console, stream = _console()
    inputs = iter(["hello", "/quit"])
    monkeypatch.setattr(console, "input", _make_input_handler(inputs))

    asyncio.run(
        run_talk_session(
            socket_path=Path("/tmp/fake.sock"),
            agent_id="inst_real",  # compatibility-only: display alias for instance_id
            json_output=False,
            console=console,
            deps=_default_talk_deps_with_alive(),
        )
    )
    output = stream.getvalue()
    assert "AGENT_BUSY" in output
    assert "processing another interact turn" in output


def test_run_talk_session_approve_inline_transitions_agent(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    class FakeStreamContext:
        async def __aenter__(self):
            return object(), object(), lambda: "session-id"

        async def __aexit__(self, exc_type, exc, tb):
            return None

    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):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return None

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

        async def call_tool(self, _tool: str, _params: dict[str, str]):
            raise AssertionError("/approve and /quit should not invoke anima.interact")

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", FakeClientSession)
    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(),
    )

    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)

    state_dir = tmp_path / ".anima"
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    write_result = write_state(
        "inst_real", _agent_state("inst_real", AgentState.awaiting_approval)
    )
    assert write_result == Success(None)

    # Mock stale state detection to bypass (test agent state handling)
    _patch_talk_default_deps(monkeypatch)

    console, stream = _console()
    inputs = iter(["/approve lgtm", "/quit"])
    monkeypatch.setattr(console, "input", _make_input_handler(inputs))

    asyncio.run(
        run_talk_session(
            socket_path=Path("/tmp/fake.sock"),
            agent_id="inst_real",  # compatibility-only: display alias for instance_id
            json_output=False,
            console=console,
            deps=_default_talk_deps_with_alive(),
        )
    )
    state_result = read_state("inst_real")
    assert isinstance(state_result, Success)
    assert state_result.unwrap().state == AgentState.running

    approval_result = read_approval_response("inst_real")
    assert isinstance(approval_result, Success)
    approval = approval_result.unwrap()
    assert approval["approved"] is True
    assert approval["note"] == "lgtm"

    output = stream.getvalue().lower()
    assert "approved inline" in output


def test_run_talk_session_reject_inline_transitions_agent(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    class FakeStreamContext:
        async def __aenter__(self):
            return object(), object(), lambda: "session-id"

        async def __aexit__(self, exc_type, exc, tb):
            return None

    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):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return None

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

        async def call_tool(self, _tool: str, _params: dict[str, str]):
            raise AssertionError("/reject and /quit should not invoke anima.interact")

    mcp_module = types.ModuleType("mcp")
    setattr(mcp_module, "ClientSession", FakeClientSession)
    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(),
    )

    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)

    state_dir = tmp_path / ".anima"
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    write_result = write_state(
        "inst_real", _agent_state("inst_real", AgentState.awaiting_approval)
    )
    assert write_result == Success(None)

    # Mock stale state detection to bypass (test agent state handling)
    _patch_talk_default_deps(monkeypatch)

    console, stream = _console()
    inputs = iter(["/reject insufficient evidence", "/quit"])
    monkeypatch.setattr(console, "input", _make_input_handler(inputs))

    asyncio.run(
        run_talk_session(
            socket_path=Path("/tmp/fake.sock"),
            agent_id="inst_real",  # compatibility-only: display alias for instance_id
            json_output=False,
            console=console,
            deps=_default_talk_deps_with_alive(),
        )
    )
    state_result = read_state("inst_real")
    assert isinstance(state_result, Success)
    assert state_result.unwrap().state == AgentState.running

    output = stream.getvalue().lower()
    assert "rejected. reason sent to agent" in output


def test_talk_impl_reaches_asyncio_run(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    state_dir = tmp_path / ".anima"
    monkeypatch.setenv("ANIMA_STATE", str(state_dir))
    state = _agent_state("inst_real", AgentState.running)
    write_result = write_state(state.instance_id, state)
    assert write_result == Success(None)

    socket_path = state_dir / "agents" / state.agent_id / "mcp.sock"
    socket_path.write_text("socket-present")

    called = {"value": False}

    def fake_run(coro: Any) -> None:
        called["value"] = True
        coro.close()

    # Mock stale state detection to bypass (test asyncio.run path)
    _patch_talk_default_deps(monkeypatch)

    monkeypatch.setattr(asyncio, "run", fake_run)
    console, stream = _console()

    talk_impl("inst_real", json_output=False, console=console)

    assert called["value"] is True
    assert "attaching to agent" in stream.getvalue().lower()
