"""Chat command implementation (spawn + talk + cleanup).

Spec source: plan step ``cli-chat-command.implement-chat-command``.
"""

# @invar:allow function_size: CLI orchestration combining spawn, attach, and cleanup phases
# @invar:allow shell_result: CLI command uses sys.exit for user-facing error handling

from __future__ import annotations

import asyncio
import sys
import time
from dataclasses import replace
from io import StringIO
from pathlib import Path

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

from anima.app.session_service import (
    ChatAttachRequest,
    DefaultSessionService,
    SessionAttachProjection,
)
from anima.core.ids import generate_instance_id
from anima.core.lifecycle import is_terminal
from anima.core.schemas import AnimaError, InternalError
from anima.shell.cli_commands.run_spawn import spawn_impl
from anima.shell.cli_commands.talk_deps import default_deps
from anima.shell.cli_commands.talk_runtime import run_talk_session
from anima.shell.state import ShellSessionProjectionPort, delete_agent_state, read_state
from anima.shell.agent_kill import kill_agent_tree


# @invar:allow shell_result: Spawn orchestration captures CLI output and exits on failures
# @shell_complexity: Spawn wrapper branches across subprocess exit outcomes and parse guards
def _spawn_chat_agent(
    *,
    task: str,
    persona: str | None,
    system_prompt: str | None,
    job_file: str | None,
    model: str | None,
    llm_base_url: str | None,
    mcp: tuple[str, ...],
    tools: tuple[str, ...],
    timeout: str | None,
    max_turns: int | None,
    max_tokens: int | None,
    context_mode: str | None,
    context_bundle: str | None,
    instance_id: str | None,
    completion_policy: str | None,
    token_file: str | None,
    sandbox: bool,
    sandbox_allow: tuple[str, ...],
    memory_provider: str | None,
    json_output: bool,
    console: Console,
) -> str:
    """Spawn chat agent via existing spawn helper and return instance id."""
    resolved_instance_id = instance_id or generate_instance_id()
    spawn_buffer = StringIO()
    spawn_console = Console(
        file=spawn_buffer,
        force_terminal=False,
        color_system=None,
    )

    try:
        spawn_impl(
            task=task,
            persona=persona,
            system_prompt=system_prompt,
            job_file=job_file,
            model=model,
            llm_base_url=llm_base_url,
            mcp=mcp,
            tools=tools,
            timeout=timeout,
            max_turns=max_turns,
            max_tokens=max_tokens,
            context_mode=context_mode,
            context_bundle=context_bundle,
            instance_id=resolved_instance_id,
            completion_policy=completion_policy or "explicit",
            token_file=token_file,
            lifecycle="daemon",
            json_output=True,
            sandbox=sandbox,
            sandbox_allow=sandbox_allow,
            auto_rm=False,
            memory_provider=memory_provider,
            console=spawn_console,
        )
    except SystemExit as exit_signal:
        captured_output = spawn_buffer.getvalue().strip()
        exit_code = exit_signal.code if isinstance(exit_signal.code, int) else 1

        if exit_code != 0:
            # On error, always show spawn output for diagnostics
            if captured_output:
                console.print(captured_output)
            sys.exit(exit_code)

        # On success in interactive mode, suppress verbose JSON spawn output
        if json_output and captured_output:
            console.print(captured_output)

        return resolved_instance_id

    console.print("[red]Error: spawn_impl returned without exiting[/red]")
    sys.exit(1)


# @invar:allow shell_result: Polling helper returns readiness tuple for CLI orchestration
# @shell_complexity: Poll loop branches across socket readiness and terminal-state transitions
# @shell_orchestration: Poll loop delegates attach projection loading through app session service
def _wait_for_socket_or_terminal(
    *,
    instance_id: str,
    timeout_seconds: float,
) -> SessionAttachProjection | None:
    """Wait for an attach projection that is interactive-ready or terminal."""
    session_service = DefaultSessionService(
        projection_port=ShellSessionProjectionPort()
    )
    last_projection: SessionAttachProjection | None = None

    deadline = time.monotonic() + timeout_seconds
    while time.monotonic() < deadline:
        projection_result = session_service.project_chat_attach(
            ChatAttachRequest(instance_id=instance_id)
        )
        if isinstance(projection_result, Success):
            projection = projection_result.unwrap()
            last_projection = projection
            if projection.presentation.kind in {"interactive_ready", "terminal_state"}:
                return projection

        time.sleep(0.1)

    projection_result = session_service.project_chat_attach(
        ChatAttachRequest(instance_id=instance_id)
    )
    if isinstance(projection_result, Success):
        return projection_result.unwrap()

    return last_projection


# @shell_complexity: Cleanup path branches on state lookup, terminal check, and best-effort deletion warnings
def _cleanup_chat_agent(*, instance_id: str, console: Console) -> None:
    """Kill (if needed) then delete spawned chat agent state."""
    state_result = read_state(instance_id)
    if isinstance(state_result, Failure):
        return

    state = state_result.unwrap()
    if not is_terminal(state.state):
        kill_result = kill_agent_tree(
            instance_id,
            error="Killed by chat session cleanup",
        )
        if isinstance(kill_result, Failure):
            # Suppress benign ENOENT - process already exited or state already cleaned up
            failure = kill_result.failure()
            if not _is_enoent_error(failure):
                console.print(
                    f"[yellow]Warning: Failed to kill chat agent {instance_id}: "
                    f"{failure}[/yellow]"
                )

    delete_result = delete_agent_state(instance_id)
    if isinstance(delete_result, Failure):
        # Suppress benign ENOENT - state already deleted (e.g., external cleanup)
        failure = delete_result.failure()
        if not _is_enoent_error(failure):
            console.print(
                f"[yellow]Warning: Failed to delete chat agent {instance_id}: "
                f"{failure}[/yellow]"
            )


# @shell_complexity: Cleanup path branches on state lookup, terminal check, and best-effort deletion warnings
# @invar:allow shell_result: Helper for cleanup warning suppression, not I/O
# @shell_orchestration: ENOENT classification is command-local cleanup policy for shell error handling
def _is_enoent_error(error: AnimaError) -> bool:
    """Check if error is a benign 'file not found' error."""
    # Check for InternalError with ENOENT in the message
    if isinstance(error, InternalError):
        return (
            "No such file or directory" in error.message or "[Errno 2]" in error.message
        )
    return False


# @invar:allow function_size: Click entry orchestrates spawn/attach/cleanup control flow
# @shell_complexity: Chat flow branches across early-terminal, socket-missing, interactive attach, and preserve/cleanup exits
def chat_impl(
    *,
    task: str | None,
    persona: str | None,
    system_prompt: str | None,
    job_file: str | None,
    model: str | None,
    llm_base_url: str | None,
    mcp: tuple[str, ...],
    tools: tuple[str, ...],
    timeout: str | None,
    max_turns: int | None,
    max_tokens: int | None,
    context_mode: str | None,
    context_bundle: str | None,
    instance_id: str | None,
    completion_policy: str | None,
    token_file: str | None,
    sandbox: bool,
    sandbox_allow: tuple[str, ...],
    keep: bool,
    json_output: bool,
    memory_provider: str | None,
    console: Console,
) -> None:
    """Run chat command: spawn daemon agent, chat interactively, then cleanup by default."""
    # When task is None, spawn agent without initial task (empty string)
    # This allows entering interactive session directly
    effective_task = task if task is not None else ""
    spawned_instance_id = _spawn_chat_agent(
        task=effective_task,
        persona=persona,
        system_prompt=system_prompt,
        job_file=job_file,
        model=model,
        llm_base_url=llm_base_url,
        mcp=mcp,
        tools=tools,
        timeout=timeout,
        max_turns=max_turns,
        max_tokens=max_tokens,
        context_mode=context_mode,
        context_bundle=context_bundle,
        instance_id=instance_id,
        completion_policy=completion_policy,
        token_file=token_file,
        sandbox=sandbox,
        sandbox_allow=sandbox_allow,
        memory_provider=memory_provider,
        json_output=json_output,
        console=console,
    )

    detach_state = {"requested": False}
    deps = default_deps()

    async def _read_input_with_detach_alias(prompt_session: object) -> str | None:
        message = await deps.read_input(prompt_session)
        if isinstance(message, str) and message.strip().lower() == "/detach":
            detach_state["requested"] = True
            return "/quit"
        return message

    chat_deps = replace(deps, read_input=_read_input_with_detach_alias)

    attach_projection = _wait_for_socket_or_terminal(
        instance_id=spawned_instance_id,
        timeout_seconds=5.0,
    )

    # Session service returns projection with terminal-state-first precedence
    if attach_projection is None:
        if json_output:
            console.print_json(
                data={
                    "error": {
                        "code": "AGENT_NOT_FOUND",
                        "numeric_code": 300,
                        "message": "Unable to load attach projection after spawn",
                        "details": {"agent_id": spawned_instance_id},
                    }
                }
            )
        else:
            console.print(
                "[red]Error: Unable to load attach projection after spawn.[/red]"
            )
        _cleanup_chat_agent(instance_id=spawned_instance_id, console=console)
        sys.exit(1)

    legacy_socket_path: str | None = None
    if isinstance(attach_projection, tuple):
        candidate_path, terminal_before_attach = attach_projection
        legacy_socket_path = str(candidate_path) if candidate_path is not None else None
        if terminal_before_attach:
            if json_output:
                console.print_json(
                    data={
                        "event": "chat_terminal_before_interact",
                        "instance_id": spawned_instance_id,
                        "state": "completed",
                    }
                )
            else:
                console.print(
                    "[dim]Agent reached terminal state (completed) "
                    "before interactive attach.[/dim]"
                )
            _cleanup_chat_agent(instance_id=spawned_instance_id, console=console)
            sys.exit(0)

        presentation = None
        mutable_state = None
    else:
        presentation = attach_projection.presentation
        mutable_state = attach_projection.mutable_state

    # Terminal state outranks transport failure (per spec)
    if presentation is not None and presentation.kind == "terminal_state":
        assert mutable_state is not None
        terminal_state = presentation.terminal_state or mutable_state.state
        if json_output:
            console.print_json(
                data={
                    "event": "chat_terminal_before_interact",
                    "instance_id": spawned_instance_id,
                    "state": terminal_state.value,
                }
            )
        else:
            console.print(
                f"[dim]Agent reached terminal state ({terminal_state.value}) "
                "before interactive attach.[/dim]"
            )
        _cleanup_chat_agent(instance_id=spawned_instance_id, console=console)
        sys.exit(0)

    if presentation is not None and presentation.kind == "non_interactive_state":
        assert mutable_state is not None
        state_message = f"Agent not interactive (state: {mutable_state.state.value})"
        if json_output:
            console.print_json(
                data={
                    "error": {
                        "code": "AGENT_NOT_INTERACTIVE",
                        "numeric_code": 314,
                        "message": state_message,
                        "details": {
                            "agent_id": spawned_instance_id,
                            "state": mutable_state.state.value,
                        },
                    }
                }
            )
        else:
            console.print(f"[red]Error: {state_message}[/red]")
        _cleanup_chat_agent(instance_id=spawned_instance_id, console=console)
        sys.exit(1)

    if presentation is not None and presentation.kind == "transport_failure":
        assert mutable_state is not None
        transport_failure = presentation.transport_failure
        socket_path_str = attach_projection.session_state.transport.socket_path
        if (
            transport_failure is not None
            and transport_failure.code == "socket_not_found"
        ):
            if json_output:
                console.print_json(
                    data={
                        "error": {
                            "code": "MCP_SOCKET_NOT_FOUND",
                            "numeric_code": 321,
                            "message": transport_failure.message,
                            "details": {
                                "agent_id": spawned_instance_id,
                                "socket_path": socket_path_str,
                            },
                        }
                    }
                )
            else:
                console.print(
                    f"[red]Error: Agent MCP socket not found at {socket_path_str}[/red]"
                )
                console.print("[dim]Agent may have exited early after spawn.[/dim]")
        else:
            if json_output:
                console.print_json(
                    data={
                        "error": {
                            "code": "MCP_CONNECTION_FAILED",
                            "numeric_code": 320,
                            "message": transport_failure.message
                            if transport_failure is not None
                            else "Agent transport is disconnected",
                            "details": {
                                "agent_id": spawned_instance_id,
                                "socket_path": socket_path_str,
                            },
                        }
                    }
                )
            else:
                console.print("[red]Error: Agent transport is disconnected[/red]")
        _cleanup_chat_agent(instance_id=spawned_instance_id, console=console)
        sys.exit(1)

    # Interactive ready - extract socket path from projection
    socket_path_str = (
        attach_projection.session_state.transport.socket_path
        if presentation is not None
        else legacy_socket_path
    )
    if socket_path_str is None:
        if json_output:
            console.print_json(
                data={
                    "error": {
                        "code": "INTERNAL_ERROR",
                        "numeric_code": 10,
                        "message": "Missing socket path for interactive attach",
                        "details": {"agent_id": spawned_instance_id},
                    }
                }
            )
        else:
            console.print(
                "[red]Error: Missing socket path for interactive attach.[/red]"
            )
        preserve_instance = keep or detach_state["requested"]
        if preserve_instance:
            sys.exit(0)
        _cleanup_chat_agent(instance_id=spawned_instance_id, console=console)
        sys.exit(1)

    socket_path = Path(socket_path_str)
    if not json_output:
        console.print(f"[green]Starting chat with {spawned_instance_id[:8]}[/green]")
        console.print(
            "[dim]Type messages. /help for commands, /detach to preserve and exit.[/dim]"
        )

    try:
        state_result = read_state(spawned_instance_id)
        persona_name = (
            state_result.unwrap().persona_name or "agent"
            if isinstance(state_result, Success)
            else "agent"
        )
        asyncio.run(
            run_talk_session(
                socket_path=socket_path,
                agent_id=spawned_instance_id,
                json_output=json_output,
                console=console,
                deps=chat_deps,
                persona_name=persona_name,
            )
        )
    except KeyboardInterrupt:
        pass  # Clean exit on Ctrl+C

    preserve_instance = keep or detach_state["requested"]
    if preserve_instance:
        if json_output:
            console.print_json(
                data={
                    "event": "chat_preserved",
                    "instance_id": spawned_instance_id,
                }
            )
        else:
            console.print(
                "[dim]Session detached; instance preserved: "
                f"{spawned_instance_id}[/dim]"
            )
        sys.exit(0)

    try:
        _cleanup_chat_agent(instance_id=spawned_instance_id, console=console)
    except (KeyboardInterrupt, OSError):
        pass  # Best-effort cleanup on exit
    sys.exit(0)
