"""Outer-loop exception handler for the conversation turn loop: phase-aware classification
(interpreter shutdown, deterministic local post-processing bug vs API-path failure),
unanswered tool_call error results, and the per-turn error cap that stops the loop from
spinning until the budget is gone. Nothing here imports ``agent.conversation_loop`` at
module level (cycle); loop-internal constants resolve lazily.
"""

from __future__ import annotations

from dataclasses import dataclass
import logging
import os
import sys
from typing import Any

from agent.message_metadata import append_message
from agent.turn_failure_copy import short_detail, site_copy

logger = logging.getLogger("agent.conversation_loop")


@dataclass
class OuterErrorVerdict:
    """``action`` is ``"break"`` (turn ends with ``final_response``/``turn_exit_reason`` set)
    or ``"fallthrough"`` (retry the iteration). The other fields are the loop locals the
    handler rebinds."""

    action: str
    _outer_error_count: Any
    _turn_exit_reason: Any
    failed: Any
    final_response: Any


def handle_outer_loop_error(
    agent: Any, *, e: Any, _outer_error_count: Any, api_call_count: Any, messages: Any,
    conversation_history: Any, _turn_exit_reason: Any, failed: Any, final_response: Any,
) -> OuterErrorVerdict:
    """Handle an exception that escaped the response-processing block. Shutdown and
    local-processing errors are deterministic and end the turn; API-path errors retry until
    ``min(_MAX_OUTER_LOOP_ERRORS, max_iterations)`` escaped exceptions. The assistant
    message is never appended here: a prefill/interim assistant may already be the tail
    (assistant→assistant); ``finalize_turn`` appends only when safe."""
    from agent.conversation_loop import (
        _API_CALL_MODULES, _LOCAL_PROCESSING_MODULES, _MAX_OUTER_LOOP_ERRORS,
        _is_interpreter_shutdown_error, _ra,
    )

    def _verdict(action: str) -> OuterErrorVerdict:
        return OuterErrorVerdict(
            action=action, _outer_error_count=_outer_error_count,
            _turn_exit_reason=_turn_exit_reason, failed=failed, final_response=final_response,
        )

    # Count every escaped exception before classification so permanent failures
    # terminate even with an unlimited turn budget.
    _outer_error_count += 1

    # Interpreter shutdown makes every executor op raise: break.
    # Phase-aware error classification. The huge outer try/except spans both the actual API request and all
    # local post-processing of the returned assistant message. Deterministic local bugs (e.g. passing a
    # multimodal content list into a regex helper after a vision turn or context compaction) should not be
    # retried: they will fail identically on every iteration and only burn the iteration budget. We classify
    # an error as local by inspecting the traceback: if the exception propagated through any of the known
    # local post-processing helpers and never entered the interruptible API-call helpers, it is almost
    # certainly a local processing bug. (#66267) Interpreter shutdown: if the process is tearing down, every
    # executor-backed operation (API call, tool dispatch, memory sync) raises ``RuntimeError: cannot
    # schedule new futures after interpreter shutdown``. Retrying is pointless — the executor is gone for
    # good — and each retry just spams another traceback. Break immediately so the turn exits cleanly.
    # (#93217)
    if sys.is_finalizing() or _is_interpreter_shutdown_error(e):
        error_msg = f"Interpreter is shutting down — cannot continue (API call #{api_call_count}): {e}"
        try:
            agent._safe_print(f"❌ {error_msg}", diagnostic=True)
        except (OSError, ValueError):
            pass
        logger.warning(error_msg)
        # Best-effort persist — the dying executor may raise the same error; don't let
        # it mask the shutdown exit. finalize_turn retries.
        try:
            agent._persist_session(messages, conversation_history)
        except Exception:
            pass
        _turn_exit_reason = "interpreter_shutdown"
        failed = True
        _sid = getattr(agent, "session_id", None)
        final_response = site_copy(
            "interpreter_shutdown", resume=f" (CLI: `hermes --resume {_sid}`)" if _sid else "",
        )
        return _verdict("break")

    # Deterministic local post-processing bugs (traceback via local helpers, never API
    # helpers) aren't retried.
    tb_module_names: set[str] = set()
    _tb = e.__traceback__
    while _tb is not None:
        tb_module_names.add(os.path.splitext(os.path.basename(_tb.tb_frame.f_code.co_filename))[0])
        _tb = _tb.tb_next
    _is_local_processing_error = bool(tb_module_names & _LOCAL_PROCESSING_MODULES) and not (
        tb_module_names & _API_CALL_MODULES
    )

    if _is_local_processing_error:
        error_msg = f"Error during local message processing after API call #{api_call_count}: {str(e)}"
    else:
        error_msg = f"Error during API call #{api_call_count}: {str(e)}"
    # Honor the _vprint contract: suppress_status_output silences hard failures;
    # quiet_mode -q still shows them. Traceback is logged below.
    if getattr(agent, "suppress_status_output", False):
        logger.error(error_msg)
    else:
        try:
            agent._safe_print(f"❌ {error_msg}", diagnostic=True)
        except (OSError, ValueError):
            logger.error(error_msg)

    # ERROR level with traceback so outer-loop failures land in agent.log AND errors.log.
    logger.exception("Outer loop error in API call #%d", api_call_count)

    # An appended assistant tool_calls message needs a role="tool" result per
    # tool_call_id; fill in error results for unanswered ones.
    for idx in range(len(messages) - 1, -1, -1):
        msg = messages[idx]
        if not isinstance(msg, dict):
            break
        if msg.get("role") == "tool":
            continue
        if msg.get("role") == "assistant" and msg.get("tool_calls"):
            answered_ids = {
                m["tool_call_id"]
                for m in messages[idx + 1:]
                if isinstance(m, dict) and m.get("role") == "tool"
            }
            for tc in msg["tool_calls"]:
                if tc and isinstance(tc, dict) and tc["id"] not in answered_ids:
                    append_message(messages, {
                        "role": "tool",
                        "name": _ra().AIAgent._get_tool_call_name_static(tc),
                        "tool_call_id": tc["id"],
                        "content": f"Error executing tool: {error_msg}",
                    })
        break

    # Non-tool errors are already printed; a synthetic message would pollute history
    # and risk breaking role alternation.

    # Local errors are deterministic: stop early instead of retrying until the budget is
    # gone; a small per-turn cap prevents infinite spinning.
    _outer_error_cap = min(_MAX_OUTER_LOOP_ERRORS, max(1, agent.max_iterations))
    if (
        _is_local_processing_error
        or api_call_count >= agent.max_iterations - 1
        or _outer_error_count >= _outer_error_cap
    ):
        # finalize_turn stamps failure_reason=loop_error from the exit reason so the desktop
        # card stops reading these as "unknown"/retryable. The deterministic local bug keeps
        # ``failed`` as it was (an incomplete, not failed, turn): flipping it made every such
        # child run a strike against the kanban dispatcher breaker.
        detail = short_detail(e)
        if _is_local_processing_error:
            _turn_exit_reason = f"local_processing_error({error_msg[:80]})"
            final_response = site_copy("local_processing_error", detail=detail)
        elif _outer_error_count >= _outer_error_cap:
            failed = True
            _turn_exit_reason = f"repeated_outer_errors({error_msg[:80]})"
            final_response = site_copy("loop_error", detail=detail)
        else:
            _turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})"
            final_response = site_copy("loop_error", detail=detail)
        return _verdict("break")
    return _verdict("fallthrough")
