"""
V2 API sessions and real-time conversation management.

Handles session management, step execution, tool confirmation, and event streaming
for real-time conversation interactions.
"""

import asyncio
import atexit
import dataclasses
import logging
import os
import threading
import uuid
from collections import defaultdict
from collections.abc import Generator, Iterable
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from .acp_session_runtime import AcpSessionRuntime

import flask
from flask import request

from gptme.config import ChatConfig, Config

from ..dirs import get_logs_dir
from ..executor import prepare_execution_environment
from ..hooks import HookType, trigger_hook
from ..hooks.confirm import ConfirmationResult
from ..llm import _chat_complete, _stream
from ..llm.models import get_default_model
from ..logmanager import LogManager, prepare_messages
from ..message import Message
from ..session import BaseSession
from ..telemetry import trace_function
from ..tools import ToolUse, get_tools
from .api_v2_common import (
    ConfigChangedEvent,
    ErrorEvent,
    EventType,
    msg2dict,
)
from .auth import require_auth
from .constants import DEFAULT_FALLBACK_MODEL
from .openapi_docs import (
    CONVERSATION_ID_PARAM,
    ElicitRespondRequest,
    ErrorResponse,
    InterruptRequest,
    StatusResponse,
    StepRequest,
    ToolConfirmRequest,
    api_doc,
)

logger = logging.getLogger(__name__)


# Session Management
# ------------------


class ToolStatus(Enum):
    """Status of a tool execution."""

    PENDING = "pending"
    EXECUTING = "executing"
    COMPLETED = "completed"
    SKIPPED = "skipped"
    FAILED = "failed"


@dataclass
class ToolExecution:
    """Tracks a tool execution."""

    tool_id: str
    tooluse: ToolUse
    status: ToolStatus = ToolStatus.PENDING
    auto_confirm: bool = False


@dataclass
class ConversationSession(BaseSession):
    """Session for a conversation.

    Extends BaseSession with server-specific fields for event streaming,
    tool execution tracking, and client management.

    Inherited from BaseSession:
        id: str - Session identifier
        conversation_id: str | None - Conversation/log identifier
        active: bool - Whether session is active
        created_at: datetime - Session creation timestamp
        last_activity: datetime - Last activity timestamp

    Server-specific fields:
        generating: bool - Whether LLM is currently generating
        events: list - Event queue for SSE streaming
        pending_tools: dict - Tools awaiting confirmation
        auto_confirm_count: int - Auto-confirm counter
        clients: set - Connected client IDs
        event_flag: Event - Threading event for notifications
    """

    # Server-specific fields (all have defaults, required for dataclass inheritance)
    generating: bool = False
    events: list[EventType] = field(default_factory=list)
    pending_tools: dict[str, ToolExecution] = field(default_factory=dict)
    auto_confirm_count: int = 0
    clients: set[str] = field(default_factory=set)
    event_flag: threading.Event = field(default_factory=threading.Event)

    # ACP-backed subprocess session (opt-in via use_acp=True in step request)
    use_acp: bool = False
    acp_runtime: "AcpSessionRuntime | None" = field(default=None, repr=False)
    # Index of the last user message processed through ACP mode.
    # Prevents duplicate /step calls from re-sending the same user message.
    acp_last_user_msg_index: int = -1


class SessionManager:
    """Manages conversation sessions."""

    _sessions: dict[str, ConversationSession] = {}
    _conversation_sessions: dict[str, set[str]] = defaultdict(set)

    @classmethod
    def create_session(cls, conversation_id: str) -> ConversationSession:
        """Create a new session for a conversation."""
        session_id = str(uuid.uuid4())
        session = ConversationSession(id=session_id, conversation_id=conversation_id)
        cls._sessions[session_id] = session
        cls._conversation_sessions[conversation_id].add(session_id)
        return session

    @classmethod
    def get_session(cls, session_id: str) -> ConversationSession | None:
        """Get a session by ID."""
        return cls._sessions.get(session_id)

    @classmethod
    def get_sessions_for_conversation(
        cls, conversation_id: str
    ) -> list[ConversationSession]:
        """Get all sessions for a conversation."""
        return [
            cls._sessions[sid]
            for sid in cls._conversation_sessions.get(conversation_id, set())
            if sid in cls._sessions
        ]

    @classmethod
    def add_event(cls, conversation_id: str, event: EventType) -> None:
        """Add an event to all sessions for a conversation."""
        for session in cls.get_sessions_for_conversation(conversation_id):
            session.events.append(event)
            session.touch()  # Update last_activity timestamp
            session.event_flag.set()  # Signal that new events are available

    @classmethod
    def clean_inactive_sessions(cls, max_age_minutes: int = 60) -> None:
        """Clean up inactive sessions."""
        cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=max_age_minutes)
        to_remove = []

        for session_id, session in cls._sessions.items():
            if session.last_activity < cutoff and not session.generating:
                to_remove.append(session_id)

        for session_id in to_remove:
            cls.remove_session(session_id)

    @classmethod
    def remove_session(cls, session_id: str) -> None:
        """Remove a session."""
        if session_id in cls._sessions:
            conversation_id = cls._sessions[session_id].conversation_id
            if conversation_id is None:
                raise ValueError("Server sessions must have conversation_id")

            # Trigger SESSION_END hook when removing the last session for a conversation
            is_last_session = (
                conversation_id in cls._conversation_sessions
                and len(cls._conversation_sessions[conversation_id]) == 1
                and session_id in cls._conversation_sessions[conversation_id]
            )

            if is_last_session:
                try:
                    # Load the conversation to trigger the hook
                    from ..logmanager import LogManager

                    manager = LogManager.load(conversation_id, lock=True)

                    logger.debug(
                        f"Last session for conversation {conversation_id}, triggering SESSION_END hook"
                    )
                    if session_end_msgs := trigger_hook(
                        HookType.SESSION_END,
                        manager=manager,
                    ):
                        for msg in session_end_msgs:
                            manager.append(
                                msg
                            )  # Just append, no notify needed during cleanup
                except Exception as e:
                    logger.warning(f"Failed to trigger SESSION_END hook: {e}")

            if conversation_id in cls._conversation_sessions:
                cls._conversation_sessions[conversation_id].discard(session_id)
                if not cls._conversation_sessions[conversation_id]:
                    del cls._conversation_sessions[conversation_id]

            # Close ACP runtime if present
            acp_rt = cls._sessions[session_id].acp_runtime
            if acp_rt is not None:
                _close_acp_runtime_bg(acp_rt)

            del cls._sessions[session_id]

    @classmethod
    def remove_all_sessions_for_conversation(cls, conversation_id: str) -> None:
        """Remove all sessions for a conversation."""
        for session in cls.get_sessions_for_conversation(conversation_id):
            cls.remove_session(session.id)


# ACP Health Monitor
# ------------------

_health_monitor_thread: threading.Thread | None = None
_health_monitor_stop = threading.Event()
_health_monitor_atexit_registered = False
_health_monitor_lock = threading.Lock()

# How often the health monitor runs (seconds)
_HEALTH_CHECK_INTERVAL = 30
# Max idle time before a session is cleaned up (minutes)
_SESSION_MAX_AGE_MINUTES = 60


def start_acp_health_monitor(interval: int = _HEALTH_CHECK_INTERVAL) -> None:
    """Start a background thread that periodically checks ACP subprocess health.

    The monitor:
    - Cleans up sessions idle longer than ``_SESSION_MAX_AGE_MINUTES``
    - Detects dead ACP subprocesses and removes their sessions
    - Logs subprocess lifecycle events for observability
    """
    global _health_monitor_thread, _health_monitor_atexit_registered

    def _monitor() -> None:
        while not _health_monitor_stop.wait(interval):
            try:
                _run_health_check()
            except Exception:
                logger.exception("Error in ACP health monitor")

    with _health_monitor_lock:
        if _health_monitor_thread is not None:
            logger.debug(
                "ACP health monitor already running (interval arg %ds ignored)",
                interval,
            )
            return  # Already running

        _health_monitor_stop.clear()
        _health_monitor_thread = threading.Thread(
            target=_monitor, daemon=True, name="acp-health-monitor"
        )
        _health_monitor_thread.start()
        # Register atexit handler only once — stop/start cycles re-enter this function
        # but must not accumulate duplicate registrations.
        if not _health_monitor_atexit_registered:
            atexit.register(stop_acp_health_monitor)
            _health_monitor_atexit_registered = True
    logger.info("ACP health monitor started (interval=%ds)", interval)


def stop_acp_health_monitor() -> None:
    """Stop the health monitor and clean up all remaining ACP sessions."""
    global _health_monitor_thread
    with _health_monitor_lock:
        _health_monitor_stop.set()
        if _health_monitor_thread is not None:
            _health_monitor_thread.join(timeout=5)
            if _health_monitor_thread.is_alive():
                logger.warning(
                    "ACP health monitor thread did not exit within 5s — "
                    "thread may still be running"
                )
            _health_monitor_thread = None

    # Best-effort cleanup of all ACP sessions on shutdown
    _cleanup_all_acp_sessions()


def _run_health_check() -> None:
    """Single health check iteration."""
    # 1. Clean inactive sessions (was never called before this change).
    # Note: this intentionally applies to all sessions (not just ACP ones) —
    # the health monitor acts as server-wide session hygiene in ACP deployments.
    # Non-ACP sessions idle for more than _SESSION_MAX_AGE_MINUTES are also evicted.
    SessionManager.clean_inactive_sessions(max_age_minutes=_SESSION_MAX_AGE_MINUTES)

    # 2. Check ACP subprocess health
    for session_id, session in list(SessionManager._sessions.items()):
        # Snapshot to a local variable: a concurrent _cleanup_all_acp_sessions()
        # can set session.acp_runtime = None between reads, causing AttributeError.
        acp_runtime = session.acp_runtime
        if acp_runtime is None:
            continue
        if session.generating:
            continue  # Don't disturb active generation
        if not acp_runtime.is_subprocess_alive():
            # Re-check generating flag before removing to narrow the TOCTOU window:
            # a /step request arriving between the check above and remove_session()
            # could start a generation on a session we are about to delete.
            if session.generating:
                continue
            logger.warning(
                "ACP subprocess dead for session %s (conversation=%s, pid=%s), "
                "cleaning up",
                session_id,
                session.conversation_id,
                acp_runtime.process_pid,
            )
            # Null out acp_runtime before remove_session so it doesn't
            # spawn a _close_acp_runtime_bg thread for an already-dead process.
            session.acp_runtime = None
            SessionManager.remove_session(session_id)


def _cleanup_all_acp_sessions() -> None:
    """Close all ACP runtimes (called during server shutdown).

    Uses synchronous process termination rather than ``asyncio.run()`` because
    this function is invoked from an atexit handler where the asyncio machinery
    may already be partially torn down.
    """
    acp_sessions = [
        (sid, s)
        for sid, s in list(SessionManager._sessions.items())
        if s.acp_runtime is not None
    ]
    if not acp_sessions:
        return

    logger.info("Shutting down %d ACP session(s)", len(acp_sessions))
    for session_id, session in acp_sessions:
        acp_runtime = session.acp_runtime
        try:
            if acp_runtime is None:
                continue
            acp_runtime.terminate_subprocess_sync()
            logger.debug("Closed ACP runtime for session %s", session_id)
        except Exception:
            logger.warning(
                "Failed to close ACP runtime for session %s",
                session_id,
                exc_info=True,
            )
        finally:
            # Null out acp_runtime before remove_session so it won't re-trigger
            # _close_acp_runtime_bg for an already-terminated process.
            session.acp_runtime = None
            # Remove from SessionManager to avoid stale entries surviving shutdown.
            # This is safe in the atexit path and prevents zombie sessions on
            # non-atexit calls (e.g. tests, hypothetical reload scenarios).
            SessionManager.remove_session(session_id)


# Helper Functions for Generation
# ------------------------------


def _get_use_acp_default() -> bool:
    """Return the server-wide default for ACP mode.

    Checks the ``GPTME_USE_ACP_DEFAULT`` environment variable (or its bare
    form ``USE_ACP_DEFAULT``) directly from the process environment.  When set
    to a truthy value (``1``, ``true``, ``yes``, ``on``), new sessions that
    don't explicitly pass ``use_acp`` in the step request will use ACP mode by
    default.

    Reads ``os.environ`` directly rather than going through
    :meth:`Config.from_workspace` to avoid clearing the shared
    ``_get_project_config_cached`` LRU cache on every step request.
    """
    val = os.environ.get("GPTME_USE_ACP_DEFAULT") or os.environ.get("USE_ACP_DEFAULT")
    if val is None:
        return False
    return val.lower() in ("1", "true", "yes", "on")


def _append_and_notify(manager: LogManager, session: ConversationSession, msg: Message):
    """Append a message and notify clients."""
    manager.append(msg)
    if session.conversation_id is None:
        raise ValueError("Server sessions must have conversation_id")
    SessionManager.add_event(
        session.conversation_id,
        {
            "type": "message_added",
            "message": msg2dict(msg, manager.workspace),
        },
    )


def _try_auto_name_and_notify(
    config: ChatConfig,
    messages: list[Message],
    model: str,
    conversation_id: str,
) -> None:
    """Try auto-naming and notify SSE clients on success."""
    from ..util.auto_naming import try_auto_name

    name = try_auto_name(config, messages, model)
    if name:
        config_event: ConfigChangedEvent = {
            "type": "config_changed",
            "config": config.to_dict(),
            "changed_fields": ["name"],
        }
        SessionManager.add_event(conversation_id, config_event)


def _close_acp_runtime_bg(acp_runtime: "AcpSessionRuntime") -> None:
    """Close an ACP runtime in a background thread (handles both sync and async callers)."""
    pid = acp_runtime.process_pid

    def _run() -> None:
        try:
            asyncio.run(acp_runtime.close())
            logger.debug("ACP runtime closed (pid=%s)", pid)
        except Exception:
            logger.warning("Failed to close ACP runtime (pid=%s)", pid, exc_info=True)

    t = threading.Thread(target=_run, daemon=True, name="acp-close")
    t.start()


def _iter_text_from_acp_update(update: Any) -> Iterable[str]:
    """Yield best-effort text chunks from ACP session_update payloads."""
    if update is None:
        return

    # Direct text payloads
    if isinstance(update, str):
        yield update
        return

    if isinstance(update, dict):
        if isinstance(update.get("text"), str):
            yield update["text"]
            return

        # Common shape: {message: {content: [{text: ...}]}}
        message = update.get("message")
        if isinstance(message, dict):
            content = message.get("content")
            if isinstance(content, list):
                for block in content:
                    if isinstance(block, dict) and isinstance(block.get("text"), str):
                        yield block["text"]
                return

        # Alternate shape: {content: [{text: ...}]}
        content = update.get("content")
        if isinstance(content, list):
            for block in content:
                if isinstance(block, dict) and isinstance(block.get("text"), str):
                    yield block["text"]
            return

    # Dataclass/object-style fallbacks
    text = getattr(update, "text", None)
    if isinstance(text, str):
        yield text
        return

    message = getattr(update, "message", None)
    if message is not None:
        msg_content = getattr(message, "content", None)
        if isinstance(msg_content, list):
            for block in msg_content:
                block_text = getattr(block, "text", None)
                if isinstance(block_text, str):
                    yield block_text


async def _acp_step(
    conversation_id: str,
    session: "ConversationSession",
    workspace: Path,
) -> None:
    """Run one conversation step via the per-session ACP subprocess.

    Sends all *pending* user messages (since the ACP cursor) to the ACP
    runtime and emits SSE events for the final assistant response. Tool
    execution happens autonomously inside the subprocess so no tool-confirmation
    flow is needed here.

    Limitations (compared to the in-process ``step()``):
    - No per-token streaming (response arrives in one chunk)
    - Tool confirmations are auto-approved inside the subprocess
    """

    # Validate acp_runtime is set (use explicit check, not assert which python -O disables)
    if session.acp_runtime is None:
        logger.error(
            "_acp_step called without acp_runtime for session %s", conversation_id
        )
        SessionManager.add_event(
            conversation_id,
            {"type": "error", "error": "Internal error: ACP runtime not initialized"},
        )
        session.generating = False
        return
    acp_runtime = session.acp_runtime  # snapshot to avoid TOCTOU races

    logdir = get_logs_dir() / conversation_id
    chat_config = ChatConfig.load_or_create(logdir, ChatConfig())
    prepare_execution_environment(
        workspace=workspace,
        tools=chat_config.tools,
        chat_config=chat_config,
    )

    manager = LogManager.load(conversation_id, lock=False)

    # Keep server-side hook semantics aligned with the in-process step path.
    assistant_messages = [m for m in manager.log.messages if m.role == "assistant"]
    if len(assistant_messages) == 0:
        if session_start_msgs := trigger_hook(
            HookType.SESSION_START,
            logdir=logdir,
            workspace=workspace,
            initial_msgs=manager.log.messages,
        ):
            for hook_msg in session_start_msgs:
                _append_and_notify(manager, session, hook_msg)
            manager.write()

    if pre_msgs := trigger_hook(
        HookType.STEP_PRE,
        manager=manager,
    ):
        for hook_msg in pre_msgs:
            _append_and_notify(manager, session, hook_msg)
        manager.write()

    user_messages = [m for m in manager.log.messages if m.role == "user"]
    if not user_messages:
        error_event: ErrorEvent = {
            "type": "error",
            "error": "No user message to process",
        }
        SessionManager.add_event(conversation_id, error_event)
        session.generating = False
        return

    next_user_index = session.acp_last_user_msg_index + 1
    pending_user_messages = user_messages[next_user_index:]
    if not pending_user_messages:
        duplicate_error_event: ErrorEvent = {
            "type": "error",
            "error": "No new user message to process",
        }
        SessionManager.add_event(conversation_id, duplicate_error_event)
        session.generating = False
        return

    SessionManager.add_event(conversation_id, {"type": "generation_started"})

    stream_tokens: list[str] = []

    async def _on_acp_update(_session_id: str, update: Any) -> None:
        # Best-effort bridge: forward ACP session_update text chunks to SSE.
        for chunk in _iter_text_from_acp_update(update):
            if not chunk:
                continue
            for token in chunk:
                stream_tokens.append(token)
                SessionManager.add_event(
                    conversation_id,
                    {"type": "generation_progress", "token": token},
                )

    acp_runtime.set_on_update(_on_acp_update)

    try:
        final_msg: Message | None = None

        for absolute_index, user_msg in enumerate(
            pending_user_messages,
            start=next_user_index,
        ):
            text, _raw = await acp_runtime.prompt(user_msg.content)
            final_text = "".join(stream_tokens) if stream_tokens else text
            stream_tokens.clear()
            msg = Message("assistant", final_text)
            _append_and_notify(manager, session, msg)
            manager.write()
            session.acp_last_user_msg_index = absolute_index
            final_msg = msg

        if post_msgs := trigger_hook(
            HookType.TURN_POST,
            manager=manager,
        ):
            for hook_msg in post_msgs:
                _append_and_notify(manager, session, hook_msg)

        manager.write()

        _try_auto_name_and_notify(
            chat_config,
            manager.log.messages,
            chat_config.model or "",
            conversation_id,
        )

        if final_msg is None:
            # Should not happen: pending_user_messages was non-empty above, but
            # guard explicitly instead of using assert (disabled by python -O).
            logger.warning(
                "ACP step produced no final message for conversation %s",
                conversation_id,
            )
            no_msg_event: ErrorEvent = {
                "type": "error",
                "error": "ACP step completed but produced no assistant message",
            }
            SessionManager.add_event(conversation_id, no_msg_event)
        else:
            SessionManager.add_event(
                conversation_id,
                {
                    "type": "generation_complete",
                    "message": msg2dict(final_msg, manager.workspace),
                },
            )
    except Exception as e:
        logger.exception("Error during ACP step: %s", e)
        SessionManager.add_event(conversation_id, {"type": "error", "error": str(e)})
    finally:
        acp_runtime.set_on_update(None)
        session.generating = False


def _start_acp_step_thread(
    conversation_id: str,
    session: "ConversationSession",
    workspace: Path,
) -> None:
    """Start an ACP-backed step in a background thread."""
    session.generating = True

    def _run() -> None:
        asyncio.run(_acp_step(conversation_id, session, workspace))

    t = threading.Thread(target=_run, daemon=True)
    t.start()


@trace_function("api_v2.step", attributes={"component": "api_v2"})
def step(
    conversation_id: str,
    session: ConversationSession,
    model: str,
    workspace: Path,
    branch: str = "main",
    auto_confirm: bool = False,
    stream: bool = True,
) -> None:
    """
    Generate a response and detect tools.

    This function handles generating a response from the LLM and detecting tools
    in the response. When tools are detected, it creates a pending tool record
    and either waits for confirmation or auto-confirms based on settings.

    It's designed to be used both for initial generation and for continuing
    after tool execution is complete.

    Args:
        conversation_id: The conversation ID
        session: The current session
        model: Model to use
        workspace: Workspace to use
        branch: Branch to use (default: "main")
        auto_confirm: Whether to auto-confirm tools (default: False)
        stream: Whether to stream the response (default: True)
    """

    # Load chat config and prepare execution environment
    logdir = get_logs_dir() / conversation_id
    chat_config = ChatConfig.load_or_create(logdir, ChatConfig())
    prepare_execution_environment(
        workspace=workspace,
        tools=chat_config.tools,
        chat_config=chat_config,
    )

    # Load conversation
    manager = LogManager.load(
        conversation_id,
        branch=branch,
        lock=False,
    )

    # Set the model as default before triggering hooks
    # This ensures hooks like token_awareness can access the model
    from ..llm.models import set_default_model

    set_default_model(model)

    # Trigger SESSION_START hook for new conversations
    assistant_messages = [m for m in manager.log.messages if m.role == "assistant"]
    if len(assistant_messages) == 0:
        logger.debug("New conversation detected, triggering SESSION_START hook")
        if session_start_msgs := trigger_hook(
            HookType.SESSION_START,
            logdir=logdir,
            workspace=workspace,
            initial_msgs=manager.log.messages,
        ):
            for msg in session_start_msgs:
                _append_and_notify(manager, session, msg)
            # Write messages to disk to ensure they're persisted
            manager.write()
            logger.debug("Wrote SESSION_START hook messages to disk")

    # TODO: This is not the best way to manage the chdir state, since it's
    # essentially a shared global across chats (bad), but the fix at least
    # addresses the issue where chats don't start in the directory they should.
    # If we are attempting to make a step in a conversation with only one or fewer user
    # messages, make sure we first chdir to the workspace directory (so that
    # the conversation starts in the right folder).
    # Tracked in issue #1486
    user_messages = [msg for msg in manager.log.messages if msg.role == "user"]
    if len(user_messages) <= 1:
        logger.debug(
            f"One or fewer user messages found, changing directory to workspace: {workspace}"
        )
        os.chdir(workspace)

    # Trigger STEP_PRE hook BEFORE preparing messages
    # This ensures hook messages are included in the LLM input
    if pre_msgs := trigger_hook(
        HookType.STEP_PRE,
        manager=manager,
    ):
        for msg in pre_msgs:
            _append_and_notify(manager, session, msg)
        # Write messages to disk to ensure they're persisted
        manager.write()
        logger.debug("Wrote step.pre hook messages to disk")

    # Prepare messages for the model
    msgs = prepare_messages(manager.log.messages)
    if not msgs:
        error_event: ErrorEvent = {
            "type": "error",
            "error": "No messages to process",
        }
        SessionManager.add_event(conversation_id, error_event)
        session.generating = False
        return

    # Notify clients about generation status
    SessionManager.add_event(conversation_id, {"type": "generation_started"})

    tool_format = chat_config.tool_format
    tools = None
    if tool_format == "tool":
        tools = [t for t in get_tools() if t.is_runnable]

    try:
        # Stream tokens from the model
        output = ""
        tooluses = []
        # Handle streaming vs non-streaming differently
        chunks: Iterable[str]
        if stream:
            chunks = _stream(msgs, model, tools)
        else:
            response, _metadata = _chat_complete(msgs, model, tools)
            chunks = [response]  # Wrap in list to iterate

        for token in (char for chunk in chunks for char in chunk):
            # check if interrupted
            if not session.generating:
                output += " [INTERRUPTED]"
                break

            output += token

            # Send token to clients
            SessionManager.add_event(
                conversation_id, {"type": "generation_progress", "token": token}
            )

            # Check for complete tool uses on \n
            if "\n" in token:
                if tooluses := list(ToolUse.iter_from_content(output)):
                    break
        else:
            tooluses = list(ToolUse.iter_from_content(output))

        # Persist the assistant message
        msg = Message("assistant", output)
        _append_and_notify(manager, session, msg)
        # Write immediately after assistant message to ensure it's persisted
        manager.write()
        logger.debug("Persisted assistant message and wrote to disk")

        # Trigger TURN_POST hook (turn.post - after message processing completes)
        if post_msgs := trigger_hook(
            HookType.TURN_POST,
            manager=manager,
        ):
            for msg in post_msgs:
                _append_and_notify(manager, session, msg)

        # Write messages to disk to ensure they're persisted
        # This fixes race condition where messages might not be available when log is retrieved
        manager.write()
        logger.debug("Wrote messages to disk")

        # Auto-generate display name (shared logic in util/auto_naming.py)
        _try_auto_name_and_notify(
            chat_config, manager.log.messages, model, conversation_id
        )

        # Signal message generation complete
        logger.debug("Generation complete")
        SessionManager.add_event(
            conversation_id,
            {
                "type": "generation_complete",
                "message": msg2dict(msg, manager.workspace),
            },
        )

        if len(tooluses) > 1:
            logger.warning(
                "Multiple tools per message not yet supported, expect issues"
            )

        # Handle tool use
        for tooluse in tooluses:
            # Create a tool execution record
            tool_id = str(uuid.uuid4())

            tool_exec = ToolExecution(
                tool_id=tool_id,
                tooluse=tooluse,
                auto_confirm=session.auto_confirm_count > 0 or auto_confirm,
            )
            session.pending_tools[tool_id] = tool_exec

            # Notify about pending tool
            SessionManager.add_event(
                conversation_id,
                {
                    "type": "tool_pending",
                    "tool_id": tool_id,
                    "tooluse": {
                        "tool": tooluse.tool,
                        "args": tooluse.args,
                        "content": tooluse.content,
                    },
                    "auto_confirm": tool_exec.auto_confirm,
                },
            )

            # If auto-confirm is enabled, execute the tool
            if tool_exec.auto_confirm:
                if session.auto_confirm_count > 0:
                    session.auto_confirm_count -= 1
                start_tool_execution(
                    conversation_id, session, tool_id, tooluse, model, chat_config
                )

        # Mark session as not generating
        session.generating = False

    except Exception as e:
        logger.exception(f"Error during step execution: {e}")
        SessionManager.add_event(conversation_id, {"type": "error", "error": str(e)})
        session.generating = False


def start_tool_execution(
    conversation_id: str,
    session: ConversationSession,
    tool_id: str,
    edited_tooluse: ToolUse | None,
    model: str,
    chat_config: ChatConfig,
) -> threading.Thread:
    """Execute a tool and handle its output."""

    # This function would ideally run asynchronously to not block the request
    # For simplicity, we'll run it in a thread
    @trace_function("api_v2.execute_tool", attributes={"component": "api_v2"})
    def execute_tool_thread() -> None:
        # Set context vars for hook-based confirmation
        from ..hooks import current_conversation_id, current_session_id

        current_conversation_id.set(conversation_id)
        current_session_id.set(session.id)

        # Prepare execution environment (config, tools, hooks, .env)
        prepare_execution_environment(
            workspace=chat_config.workspace,
            tools=None,
            chat_config=chat_config,
        )

        # Load the conversation
        manager = LogManager.load(conversation_id, lock=False)

        if tool_id not in session.pending_tools:
            logger.error(
                f"Tool {tool_id} not found in pending tools (may have been handled by another thread)"
            )
            return
        tool_exec = session.pending_tools[tool_id]
        tool_exec.status = ToolStatus.EXECUTING

        # use explicit tooluse if set (may be modified), else use the one from the pending tool
        tooluse: ToolUse = edited_tooluse or tool_exec.tooluse

        # Remove the tool from pending
        del session.pending_tools[tool_id]

        # Notify about tool execution
        SessionManager.add_event(
            conversation_id, {"type": "tool_executing", "tool_id": tool_id}
        )
        logger.info(f"Tool {tool_id} executing")

        # Execute the tool
        try:
            logger.info(f"Executing tool: {tooluse.tool}")
            tool_outputs = list(
                tooluse.execute(log=manager.log, workspace=manager.workspace)
            )
            logger.info(f"Tool execution complete, outputs: {len(tool_outputs)}")

            # Store the tool outputs
            for tool_output in tool_outputs:
                _append_and_notify(manager, session, tool_output)
        except Exception as e:
            logger.exception(f"Error executing tool {tooluse.__class__.__name__}: {e}")
            tool_exec.status = ToolStatus.FAILED

            msg = Message("system", f"Error: {e!s}")
            _append_and_notify(manager, session, msg)

        # This implements auto-stepping similar to the CLI behavior
        _start_step_thread(conversation_id, session, model, chat_config.workspace)

    # Start execution in a thread
    thread = threading.Thread(target=execute_tool_thread)
    thread.daemon = True
    thread.start()
    return thread


def _start_step_thread(
    conversation_id: str,
    session: ConversationSession,
    model: str,
    workspace: Path,
    branch: str = "main",
    auto_confirm: bool = False,
    stream: bool = True,
):
    """Start a step execution in a background thread."""

    # Mark as generating before starting thread to avoid race condition
    # where interrupt is called before the thread sets generating=True
    session.generating = True

    def step_thread() -> None:
        try:
            step(
                conversation_id=conversation_id,
                session=session,
                model=model,
                workspace=workspace,
                branch=branch,
                auto_confirm=auto_confirm,
                stream=stream,
            )

        except Exception as e:
            logger.exception(f"Error during step execution: {e}")
            SessionManager.add_event(
                conversation_id, {"type": "error", "error": str(e)}
            )
            session.generating = False

    # Start step execution in a thread
    thread = threading.Thread(target=step_thread)
    thread.daemon = True
    thread.start()


# API Endpoints
# ------------

sessions_api = flask.Blueprint("sessions_api", __name__)


@sessions_api.route("/api/v2/conversations/<string:conversation_id>/events")
@require_auth
@api_doc(
    summary="Subscribe to conversation events (V2)",
    description="Subscribe to real-time conversation events via Server-Sent Events stream",
    responses={200: None, 404: ErrorResponse},
    parameters=[
        {
            "name": "conversation_id",
            "in": "path",
            "required": True,
            "schema": {"type": "string"},
            "description": "Conversation ID",
        },
        {
            "name": "session_id",
            "in": "query",
            "required": False,
            "schema": {"type": "string"},
            "description": "Session ID (creates new session if not provided)",
        },
    ],
    tags=["sessions"],
)
def api_conversation_events(conversation_id: str):
    """Subscribe to conversation events."""
    session_id = request.args.get("session_id")
    if not session_id:
        # Create a new session if none provided
        session = SessionManager.create_session(conversation_id)
        session_id = session.id
    else:
        session_obj = SessionManager.get_session(session_id)
        if session_obj is None:
            return flask.jsonify({"error": f"Session not found: {session_id}"}), 404
        session = session_obj

    # Generate event stream
    def generate_events() -> Generator[str, None, None]:
        client_id = str(uuid.uuid4())
        try:
            # Add this client to the session
            session.clients.add(client_id)

            # Send initial connection event
            yield f"data: {flask.json.dumps({'type': 'connected', 'session_id': session_id})}\n\n"

            # Send immediate ping to ensure connection is established right away
            yield f"data: {flask.json.dumps({'type': 'ping'})}\n\n"

            # Create an event queue
            last_event_index = 0

            while True:
                # Check if there are new events
                if last_event_index < (new_index := len(session.events)):
                    # Send any new events
                    for event in session.events[last_event_index:new_index]:
                        yield f"data: {flask.json.dumps(event)}\n\n"
                    last_event_index = new_index

                # Wait a bit before checking again
                yield f"data: {flask.json.dumps({'type': 'ping'})}\n\n"

                # Use event.wait() with timeout to avoid busy waiting while allowing ping intervals
                # 15s timeout for connection keep-alive
                session.event_flag.wait(timeout=15)
                session.event_flag.clear()

        except GeneratorExit:
            # Client disconnected
            if session:
                session.clients.discard(client_id)
                if not session.clients:
                    # If no clients are connected, mark the session for cleanup
                    session.active = False
            raise

    return flask.Response(
        generate_events(),
        mimetype="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # Disable buffering in nginx
        },
    )


@sessions_api.route(
    "/api/v2/conversations/<string:conversation_id>/step", methods=["POST"]
)
@require_auth
@api_doc(
    summary="Take conversation step (V2)",
    description="Take a step in the conversation - generate a response or continue after tool execution",
    request_body=StepRequest,
    responses={
        200: StatusResponse,
        400: ErrorResponse,
        404: ErrorResponse,
        409: ErrorResponse,
    },
    parameters=[
        {
            "name": "conversation_id",
            "in": "path",
            "required": True,
            "schema": {"type": "string"},
            "description": "Conversation ID",
        }
    ],
    tags=["sessions"],
)
def api_conversation_step(conversation_id: str):
    """Take a step in the conversation - generate a response or continue after tool execution."""
    req_json = flask.request.json or {}
    session_id = req_json.get("session_id")

    if not session_id:
        return flask.jsonify({"error": "session_id is required"}), 400

    session = SessionManager.get_session(session_id)
    if session is None:
        return flask.jsonify({"error": f"Session not found: {session_id}"}), 404

    logdir = get_logs_dir() / conversation_id
    chat_config = ChatConfig.load_or_create(logdir, ChatConfig())

    stream = req_json.get("stream", chat_config.stream)

    # ACP opt-in: sticky once enabled for a session.
    # Default can be set server-wide via GPTME_USE_ACP_DEFAULT=true env var.
    # Validate type explicitly to avoid truthy string surprises (e.g. "false").
    _acp_default = _get_use_acp_default()
    use_acp = req_json.get("use_acp", _acp_default)
    if not isinstance(use_acp, bool):
        return (
            flask.jsonify(
                {
                    "error": "Invalid 'use_acp' value",
                    "message": "'use_acp' must be a boolean",
                }
            ),
            400,
        )

    if use_acp and not session.use_acp:
        from .acp_session_runtime import AcpSessionRuntime

        session.use_acp = True
        session.acp_runtime = AcpSessionRuntime(workspace=chat_config.workspace)
        # Lazy-start the health monitor on first ACP session — avoids unconditional
        # background thread and global session-eviction side-effects at app startup.
        start_acp_health_monitor()

    # Validate auto_confirm type explicitly (bool OR int).
    # Reject strings/floats/etc. to avoid accidental truthy coercion.
    auto_confirm = req_json.get("auto_confirm", False)
    if type(auto_confirm) not in (bool, int):
        return (
            flask.jsonify(
                {
                    "error": "Invalid 'auto_confirm' value",
                    "message": "'auto_confirm' must be a boolean or integer",
                }
            ),
            400,
        )

    # If auto_confirm set, set auto_confirm_count.
    # Check bool first: bool is a subclass of int in Python, so isinstance(True, int) is True.
    # Use type() to distinguish them correctly.
    if type(auto_confirm) is bool:
        session.auto_confirm_count = 1 if auto_confirm else -1
    else:  # int
        session.auto_confirm_count = auto_confirm

    auto_confirm_enabled = bool(session.auto_confirm_count > 0)

    if session.generating:
        return flask.jsonify({"error": "Generation already in progress"}), 409

    # Get the branch and model
    branch = req_json.get("branch", "main")
    default_model = get_default_model()

    # Get model from request, config, or default (in that order)
    model = req_json.get("model")
    if not model:
        model = chat_config.model
    if not model and default_model:
        model = default_model.full
    if not model:
        # Try to get from environment/config as last resort
        config = Config.from_workspace(workspace=chat_config.workspace)
        model = config.get_env("MODEL")
    if not model and not session.use_acp:
        # In ACP mode the subprocess manages its own model; skip this check
        return flask.jsonify(
            {
                "error": "No model specified and no default model set",
                "message": (
                    "Please specify a model in one of the following ways:\n"
                    "1. Include 'model' in the request JSON\n"
                    "2. Set MODEL environment variable when starting the server\n"
                    "3. Use --model flag when starting the server (gptme-server serve --model <model>)\n"
                    "4. Configure model in workspace chat config"
                ),
                "example_models": [
                    DEFAULT_FALLBACK_MODEL,
                    "openai/gpt-4",
                    "openai/gpt-4o-mini",
                ],
            }
        ), 400

    # Snapshot acp_runtime to avoid TOCTOU races: concurrent cleanup threads
    # (e.g. _cleanup_stale_acp_sessions) can set session.acp_runtime = None
    # between the check and the use.
    acp_runtime = session.acp_runtime if session.use_acp else None

    # If ACP mode is active, keep session runtime model aligned with the
    # resolved request/config/default model whenever available.
    if acp_runtime is not None and model:
        acp_runtime.model = model

    # Route through ACP subprocess if the session has opted in
    if acp_runtime is not None:
        _start_acp_step_thread(
            conversation_id=conversation_id,
            session=session,
            workspace=chat_config.workspace,
        )
    else:
        # model should be non-None here: the `if not model and not session.use_acp`
        # check above returns 400 for non-ACP sessions with no model.
        # Use explicit check instead of assert (which python -O disables).
        if model is None:
            return flask.jsonify(
                {"error": "Model is required for non-ACP sessions"}
            ), 400
        # Start step execution in a background thread
        # Model will be set in the worker thread by step()
        _start_step_thread(
            conversation_id=conversation_id,
            session=session,
            model=model,
            workspace=chat_config.workspace,
            branch=branch,
            auto_confirm=auto_confirm_enabled,
            stream=stream,
        )

    return flask.jsonify(
        {"status": "ok", "message": "Step started", "session_id": session_id}
    )


def _resolve_hook_confirmation(
    tool_id: str,
    action: str,
    edited_content: str | None = None,
) -> None:
    """Resolve a pending hook-based confirmation.

    This is called when the HTTP endpoint receives a tool confirmation response.
    It converts the HTTP action to a ConfirmationResult and signals any waiting hooks.

    Args:
        tool_id: The tool ID being confirmed
        action: The action (confirm, skip, edit, auto)
        edited_content: Content for edit action
    """
    try:
        from ..hooks.server_confirm import resolve_pending
    except ImportError:
        return  # Hook module not available

    # Convert HTTP action to ConfirmationResult
    if action == "confirm" or action == "auto":
        result = ConfirmationResult.confirm()
    elif action == "skip":
        result = ConfirmationResult.skip("Skipped by user")
    elif action == "edit":
        if edited_content:
            result = ConfirmationResult.edit(edited_content)
        else:
            result = ConfirmationResult.confirm()
    else:
        return  # Unknown action

    # Try to resolve - this will signal any waiting hooks
    resolve_pending(tool_id, result)


@sessions_api.route(
    "/api/v2/conversations/<string:conversation_id>/tool/confirm", methods=["POST"]
)
@require_auth
@api_doc(
    summary="Confirm tool execution (V2)",
    description="Confirm, edit, skip, or auto-confirm a pending tool execution. "
    "session_id is optional - if not provided, the tool will be found across all sessions for the conversation.",
    request_body=ToolConfirmRequest,
    responses={200: StatusResponse, 400: ErrorResponse, 404: ErrorResponse},
    parameters=[CONVERSATION_ID_PARAM],
    tags=["sessions"],
)
def api_conversation_tool_confirm(conversation_id: str):
    """Confirm or modify a tool execution.

    session_id is optional. If not provided, the tool will be found across all
    sessions for this conversation. This handles the race condition where the
    client may not have received the session_id yet when confirming a tool.
    """

    req_json = flask.request.json or {}
    session_id = req_json.get("session_id")
    tool_id = req_json.get("tool_id")
    action = req_json.get("action")

    if not tool_id or not action:
        return (
            flask.jsonify({"error": "tool_id and action are required"}),
            400,
        )

    session: ConversationSession | None = None

    if session_id:
        # If session_id provided, use it directly
        session = SessionManager.get_session(session_id)
        if session is None:
            return flask.jsonify({"error": f"Session not found: {session_id}"}), 404
        if tool_id not in session.pending_tools:
            return flask.jsonify({"error": f"Tool not found: {tool_id}"}), 404
    else:
        # If no session_id, search for the tool across all sessions for this conversation
        for sess in SessionManager.get_sessions_for_conversation(conversation_id):
            if tool_id in sess.pending_tools:
                session = sess
                break

        if session is None:
            return (
                flask.jsonify(
                    {
                        "error": f"Tool not found in any session for conversation: {tool_id}"
                    }
                ),
                404,
            )

    tool_exec = session.pending_tools[tool_id]

    logdir = get_logs_dir() / conversation_id
    chat_config = ChatConfig.load_or_create(logdir, ChatConfig())

    # Get model from session config, default model, or fallback to "anthropic"
    default_model = get_default_model()
    model = chat_config.model or (default_model.full if default_model else "anthropic")

    # Try to resolve via hook system (for hook-based confirmation flow)
    # This enables future integration where tools use hooks for confirmation
    _resolve_hook_confirmation(tool_id, action, req_json.get("content"))

    if action == "confirm":
        # Execute the tool
        tooluse = tool_exec.tooluse

        logger.info(f"Executing runnable tooluse: {tooluse}")
        start_tool_execution(
            conversation_id, session, tool_id, tooluse, model, chat_config
        )
        return flask.jsonify({"status": "ok", "message": "Tool confirmed"})

    if action == "edit":
        # Edit and then execute the tool
        edited_content = req_json.get("content")
        if not edited_content:
            return flask.jsonify({"error": "content is required for edit action"}), 400

        # Execute with edited content
        start_tool_execution(
            conversation_id,
            session,
            tool_id,
            dataclasses.replace(tool_exec.tooluse, content=edited_content),
            model,
            chat_config,
        )

    elif action == "skip":
        # Skip the tool execution
        tool_exec.status = ToolStatus.SKIPPED
        del session.pending_tools[tool_id]

        # Provide meaningful message to prevent LLM from re-suggesting the same tool
        tool_name = tool_exec.tooluse.tool
        msg = Message(
            "system",
            f"User chose not to execute this {tool_name} tool. "
            "Do not re-suggest the same action unless explicitly requested.",
        )
        _append_and_notify(LogManager.load(conversation_id, lock=False), session, msg)

        # Resume generation
        _start_step_thread(conversation_id, session, model, chat_config.workspace)

    elif action == "auto":
        # Enable auto-confirmation for future tools
        count = req_json.get("count", 1)
        if count <= 0:
            return flask.jsonify({"error": "count must be positive"}), 400

        session.auto_confirm_count = count

        # Also confirm this tool
        start_tool_execution(
            conversation_id, session, tool_id, tool_exec.tooluse, model, chat_config
        )
    else:
        return flask.jsonify({"error": f"Unknown action: {action}"}), 400

    return flask.jsonify({"status": "ok", "message": f"Tool {action}ed"})


def _resolve_hook_elicitation(
    elicit_id: str,
    action: str,
    value: str | None = None,
    values: list[str] | None = None,
) -> None:
    """Resolve a pending hook-based elicitation.

    Called by the HTTP endpoint when the client responds to an elicitation request.

    Args:
        elicit_id: The elicitation ID to resolve
        action: The action (accept, decline, cancel)
        value: Response value for text/choice/secret/confirmation/form types
        values: Selected values for multi_choice type
    """
    try:
        from ..hooks.elicitation import ElicitationResponse
        from ..hooks.server_elicit import get_pending, resolve_pending
    except ImportError:
        return  # Hook module not available

    if action == "cancel":
        result = ElicitationResponse.cancel()
    elif action == "decline":
        result = ElicitationResponse(cancelled=False, value=None)
    elif action == "accept":
        # Look up the pending request to check if sensitive (e.g. secret-type)
        pending = get_pending(elicit_id)
        is_sensitive = pending.request.sensitive if pending else False
        if values is not None:
            result = ElicitationResponse.multi(values)
        elif value is not None:
            result = ElicitationResponse.text(value, sensitive=is_sensitive)
        else:
            result = ElicitationResponse.text("", sensitive=is_sensitive)
    else:
        return  # Unknown action

    resolve_pending(elicit_id, result)


@sessions_api.route(
    "/api/v2/conversations/<string:conversation_id>/elicit/respond",
    methods=["POST"],
)
@require_auth
@api_doc(
    summary="Respond to elicitation (V2)",
    description="Respond to an agent's elicitation request with user input. "
    "Accepts values for text, choice, secret, confirmation, multi_choice, and form types.",
    request_body=ElicitRespondRequest,
    responses={200: StatusResponse, 400: ErrorResponse},
    parameters=[CONVERSATION_ID_PARAM],
    tags=["sessions"],
)
def api_conversation_elicit_respond(conversation_id: str):
    """Respond to an elicitation request from the agent.

    The agent requested structured input via the elicit tool. The client
    displays an appropriate UI and sends the user's response here.

    Note: conversation_id is accepted for URL consistency but not validated
    against elicit_id. The elicitation registry uses globally unique UUIDs,
    so cross-conversation resolution is not a practical concern.
    """
    req_json = flask.request.json or {}
    elicit_id = req_json.get("elicit_id")
    action = req_json.get("action")

    if not elicit_id or not action:
        return (
            flask.jsonify({"error": "elicit_id and action are required"}),
            400,
        )

    if action not in ("accept", "decline", "cancel"):
        return (
            flask.jsonify(
                {
                    "error": f"Unknown action: {action}. Must be accept, decline, or cancel"
                }
            ),
            400,
        )

    _resolve_hook_elicitation(
        elicit_id, action, req_json.get("value"), req_json.get("values")
    )

    return flask.jsonify({"status": "ok", "message": f"Elicitation {action}ed"})


@sessions_api.route(
    "/api/v2/conversations/<string:conversation_id>/interrupt", methods=["POST"]
)
@require_auth
@api_doc(
    summary="Interrupt conversation (V2)",
    description="Interrupt the current generation or tool execution in a conversation",
    request_body=InterruptRequest,
    responses={200: StatusResponse, 400: ErrorResponse, 404: ErrorResponse},
    parameters=[CONVERSATION_ID_PARAM],
    tags=["sessions"],
)
def api_conversation_interrupt(conversation_id: str):
    """Interrupt the current generation or tool execution."""
    req_json = flask.request.json or {}
    session_id = req_json.get("session_id")

    if not session_id:
        return flask.jsonify({"error": "session_id is required"}), 400

    session = SessionManager.get_session(session_id)
    if session is None:
        return flask.jsonify({"error": f"Session not found: {session_id}"}), 404

    if not session.generating and not session.pending_tools:
        # Idempotent: if nothing is generating, treat as already interrupted
        return flask.jsonify(
            {"status": "ok", "message": "Already interrupted or not generating"}
        )

    # Mark session as not generating
    session.generating = False

    # Clear pending tools
    session.pending_tools.clear()

    # Notify about interruption
    SessionManager.add_event(conversation_id, {"type": "interrupted"})

    return flask.jsonify({"status": "ok", "message": "Interrupted"})
