"""Middleware that repairs orphaned tool calls before model calls.

When a run is cancelled or the sandbox dies mid-tool-call, LangGraph persists the
``AIMessage`` with the ``tool_call`` but never the matching ``ToolMessage``. On the
next run a fresh human message lands where the tool result should be, so the
provider rejects the request (Anthropic: ``messages.N: `tool_use` ids were found
without `tool_result` blocks``; OpenAI raises the equivalent). That permanently
wedges the thread — every retry hits the same error.

This middleware scans the outgoing message list and inserts a synthetic error
``ToolMessage`` immediately after any ``tool_call`` whose id has no corresponding
``ToolMessage``. The agent then sees the interrupted tool as a normal tool error
and can retry instead of dying.
"""

from __future__ import annotations

import json
import logging
from collections.abc import Awaitable, Callable
from typing import Any

from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, ToolMessage

logger = logging.getLogger(__name__)

INTERRUPTED_TOOL_RECOVERY = "tool_call_interrupted"

_INTERRUPTED_TOOL_ERROR = (
    "The previous tool call did not complete — the run was interrupted (cancelled "
    "or the sandbox became unavailable) before a result was returned. No output was "
    "captured. Retry the tool call if you still need it; if repository files are "
    "missing, re-clone or reinitialize the workspace first."
)


def _iter_tool_calls(message: AIMessage) -> list[tuple[str, str | None]]:
    """Return ``(id, name)`` for each well-formed tool call on the message."""
    calls: list[tuple[str, str | None]] = []
    for tool_call in message.tool_calls or []:
        if isinstance(tool_call, dict):
            call_id = tool_call.get("id")
            name = tool_call.get("name")
        else:
            call_id = getattr(tool_call, "id", None)
            name = getattr(tool_call, "name", None)
        if isinstance(call_id, str) and call_id:
            calls.append((call_id, name if isinstance(name, str) and name else None))
    return calls


def _synthetic_tool_message(call_id: str, name: str | None) -> ToolMessage:
    payload: dict[str, str] = {
        "status": "error",
        "error_type": "InterruptedToolCall",
        "recovery": INTERRUPTED_TOOL_RECOVERY,
        "error": _INTERRUPTED_TOOL_ERROR,
    }
    if name:
        payload["name"] = name
    return ToolMessage(
        content=json.dumps(payload),
        tool_call_id=call_id,
        name=name,
        status="error",
    )


def _repair_messages(messages: list[Any]) -> list[Any] | None:
    """Insert synthetic results for orphaned tool calls; return new list or None."""
    satisfied = {
        message.tool_call_id
        for message in messages
        if isinstance(message, ToolMessage) and isinstance(message.tool_call_id, str)
    }

    repaired: list[Any] = []
    inserted = 0
    for message in messages:
        repaired.append(message)
        if not isinstance(message, AIMessage):
            continue
        for call_id, name in _iter_tool_calls(message):
            if call_id in satisfied:
                continue
            repaired.append(_synthetic_tool_message(call_id, name))
            satisfied.add(call_id)
            inserted += 1

    if not inserted:
        return None
    logger.warning("Repaired %d orphaned tool call(s) before model call", inserted)
    return repaired


class RepairOrphanedToolCallsMiddleware(AgentMiddleware):
    """Insert synthetic tool results for interrupted tool calls before model calls."""

    async def awrap_model_call(
        self,
        request: ModelRequest,
        handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
    ) -> Any:
        repaired = _repair_messages(request.messages)
        if repaired is not None:
            request.messages[:] = repaired
        return await handler(request)
