import asyncio
import json
import logging
import os
import re
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass
from typing import Any

import litellm

from otto.auth import AuthStorage
from otto.errors import classify_otto_error
from otto.log import get_logger
from otto.providers import OAuthStreamProvider, StreamProvider, resolve_provider

# Lazy import to avoid circular dependency at module level
_vision_check: Callable[[str], bool] | None = None


def _supports_vision(model_name: str) -> bool:
    global _vision_check
    if _vision_check is None:
        from otto.chat import _model_supports_vision

        _vision_check = _model_supports_vision
    return _vision_check(model_name)


log = get_logger("otto.agent")


_AGENT_LOGGERS: dict[str, Any] = {}


def _sanitize_logger_segment(value: str) -> str:
    normalized = re.sub(r"[^a-zA-Z0-9_.-]+", "-", value.strip().lower())
    normalized = normalized.strip(".-_")
    return normalized or "main"


def _resolve_agent_logger(agent_kind: str, agent_name: str | None) -> Any:
    if agent_kind == "primary":
        segment = "main"
    else:
        segment = _sanitize_logger_segment(agent_name or "main")

    logger_name = f"otto.agent.{segment}"
    cached = _AGENT_LOGGERS.get(logger_name)
    if cached is not None:
        return cached

    resolved = get_logger(logger_name)
    _AGENT_LOGGERS[logger_name] = resolved
    return resolved


_CLAUDE_CODE_MAX_TURNS = 80
_CLAUDE_CODE_MAX_TOOL_ROUNDS = 60
_DEFAULT_MAX_OUTPUT_TOKENS = 16384
_MAX_TOOL_RESULT_CHARS = 50000
_TOOL_TIMEOUT_SECONDS = 300
_SAFE_PARALLEL_TOOLS = {
    "read_file",
    "memory_search",
    "web_search",
    "code_search",
    "fetch",
    "view_image",
    "list_skills",
}
_ORCHESTRATION_TOOLS = {
    "delegate_task",
    "list_jobs",
    "cancel_job",
    "retry_job",
    "redirect_job",
}
_COMPACTION_KEEP_RECENT_MESSAGES = 8
_COMPACTION_MARKER = "[context summary]"
_COMPACTION_MAX_SOURCE_CHARS = 120_000
_REASONING_RETRY_NOT_APPLICABLE = object()
_REASONING_VALUE_RE = re.compile(r"[\"']([a-zA-Z0-9_-]+)[\"']")
_REASONING_FALLBACK_ORDER = ("none", "minimal", "low", "medium", "high")

_COMPACTION_SYSTEM_PROMPT = """
You are compacting prior chat context for a coding agent.
Return concise markdown with this exact structure:

## Goal
## Constraints & Preferences
## Progress
### Done
### In Progress
### Blocked
## Key Decisions
## Next Steps
## Critical Context

Include file tracking tags at the end:
<read-files>
path/one
</read-files>
<modified-files>
path/two
</modified-files>

Do not invent facts. Use only the provided conversation transcript.
""".strip()


def _max_output_tokens(model: str) -> int:
    """Compute max output tokens as model_max / 3, capped at the model max."""
    litellm_model = model.split("/", 1)[-1] if "/" in model else model
    try:
        info = litellm.get_model_info(litellm_model)
        max_output = int((info or {}).get("max_output_tokens") or 0)
    except Exception:
        max_output = 0
    if max_output > 0:
        return min(max_output, max(max_output // 3, _DEFAULT_MAX_OUTPUT_TOKENS))
    return _DEFAULT_MAX_OUTPUT_TOKENS


def _parse_supported_reasoning_efforts(error_message: str) -> tuple[str, ...]:
    marker = "supported values are:"
    lower = error_message.lower()
    start = lower.find(marker)
    if start < 0:
        return ()

    tail = error_message[start + len(marker) :]
    values: list[str] = []
    for raw in _REASONING_VALUE_RE.findall(tail):
        normalized = raw.strip().lower()
        if not normalized or normalized in values:
            continue
        values.append(normalized)
    return tuple(values)


def _reasoning_effort_retry_target(
    reasoning_effort: str | None,
    error_message: str,
) -> object | str | None:
    if reasoning_effort is None:
        return _REASONING_RETRY_NOT_APPLICABLE

    lower = error_message.lower()
    if "reasoning_effort" not in lower:
        return _REASONING_RETRY_NOT_APPLICABLE

    current = reasoning_effort.strip().lower()
    if current in {"off", "disabled"}:
        current = "none"

    supported_values = _parse_supported_reasoning_efforts(error_message)
    if supported_values:
        if current in supported_values:
            return _REASONING_RETRY_NOT_APPLICABLE
        if current == "xhigh" and "high" in supported_values:
            return "high"

        for candidate in _REASONING_FALLBACK_ORDER:
            if candidate in supported_values and candidate != current:
                return candidate
        return None

    if "unsupported value" in lower or "invalid value" in lower or "does not support" in lower:
        return None

    return _REASONING_RETRY_NOT_APPLICABLE


litellm.suppress_debug_info = True
for _logger_name in ("litellm", "LiteLLM"):
    logging.getLogger(_logger_name).setLevel(logging.CRITICAL)

# Set app identity for providers that surface it (OpenRouter, etc.)
# litellm checks these env vars before falling back to "liteLLM" defaults.
os.environ.setdefault("OR_APP_NAME", "Otto")
os.environ.setdefault("OR_SITE_URL", "")


@dataclass(frozen=True)
class Tool:
    name: str
    description: str
    parameters: dict
    execute: Callable[..., Awaitable[str | dict]]
    timeout_seconds: int | None = None


@dataclass(frozen=True)
class Turn:
    text: str | None
    tool_calls: list[dict]
    usage: dict


@dataclass(frozen=True)
class AgentResult:
    text: str
    turns: list[Turn]
    total_usage: dict


async def run(
    model: str,
    system: str,
    messages: list[dict],
    tools: list[Tool],
    *,
    auth_storage: AuthStorage | None = None,
    max_turns: int = 50,
    compaction_enabled: bool = True,
    compaction_threshold: float = 0.8,
    compaction_keep_recent_messages: int = _COMPACTION_KEEP_RECENT_MESSAGES,
    reasoning_effort: str | None = None,
    on_text: Callable[[str], None] | None = None,
    on_tool_start: Callable[[str, dict], None] | None = None,
    on_tool_end: Callable[[str, str], None] | None = None,
    agent_kind: str = "primary",
    delegation_id: str | None = None,
    agent_name: str | None = None,
    provider: StreamProvider | None = None,
) -> AgentResult:
    tool_schemas = [_tool_to_schema(tool) for tool in tools]
    tools_by_name = {tool.name: tool for tool in tools}
    run_log = _resolve_agent_logger(agent_kind, agent_name)
    log_context: dict[str, Any] = {"agent_kind": agent_kind}
    if delegation_id is not None:
        log_context["delegation_id"] = delegation_id

    turns: list[Turn] = []
    total_prompt_tokens = 0
    total_completion_tokens = 0
    total_cost = 0.0
    final_text = ""

    if model.lower().startswith("claude-code/"):
        max_turns = min(max_turns, _CLAUDE_CODE_MAX_TURNS)

    if max_turns <= 0:
        return AgentResult(
            text=final_text,
            turns=turns,
            total_usage={
                "prompt_tokens": total_prompt_tokens,
                "completion_tokens": total_completion_tokens,
                "cost": total_cost,
            },
        )

    is_claude_code = model.lower().startswith("claude-code/")
    tool_rounds = 0
    clean_exit = False
    delegation_handoff_active = False
    if provider is None:
        provider = await resolve_provider(model, system, auth_storage=auth_storage)

    for turn_index in range(max_turns):
        try:
            effective_system = system
            if isinstance(provider, OAuthStreamProvider):
                effective_system = provider.effective_system(system)
            messages = await _compact_context(
                model,
                effective_system,
                messages,
                provider,
                enabled=compaction_enabled,
                threshold_ratio=compaction_threshold,
                keep_recent_messages=compaction_keep_recent_messages,
                log_context=log_context,
                logger=run_log,
            )
            messages = _trim_to_context_window(
                model,
                effective_system,
                messages,
                log_context=log_context,
                logger=run_log,
            )
            run_log.info(
                "llm request",
                model=model,
                turn=turn_index,
                messages=len(messages),
                **log_context,
            )

            request_messages = _messages_for_request(effective_system, messages)
            response_stream: AsyncIterator[Any] | None = None

            async def _request_stream(effort: str | None) -> AsyncIterator[Any]:
                return await provider.stream_completion(
                    model=model,
                    messages=request_messages,
                    tools=tool_schemas,
                    tool_choice="auto" if tool_schemas else None,
                    max_tokens=_max_output_tokens(model) if is_claude_code else None,
                    reasoning_effort=effort,
                )

            try:
                response_stream = await _request_stream(reasoning_effort)
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                retry_target = _reasoning_effort_retry_target(reasoning_effort, str(exc))
                if retry_target is not _REASONING_RETRY_NOT_APPLICABLE:
                    fallback_effort = retry_target if isinstance(retry_target, str) else None
                    if fallback_effort != reasoning_effort:
                        run_log.warning(
                            "reasoning effort incompatible; retrying",
                            model=model,
                            turn=turn_index,
                            requested_reasoning_effort=reasoning_effort,
                            fallback_reasoning_effort=fallback_effort,
                            error=str(exc),
                            **log_context,
                        )
                        reasoning_effort = fallback_effort
                        try:
                            response_stream = await _request_stream(reasoning_effort)
                        except asyncio.CancelledError:
                            raise
                        except Exception as retry_exc:
                            exc = retry_exc
                        else:
                            exc = None
                if exc is not None:
                    mapped_exc = classify_otto_error(exc)
                    run_log.error(
                        "llm request failed",
                        model=model,
                        turn=turn_index,
                        error=str(exc),
                        error_type=type(mapped_exc).__name__,
                        **log_context,
                    )
                    if mapped_exc is exc:
                        raise
                    raise mapped_exc from exc
            if response_stream is None:
                raise RuntimeError("llm stream unavailable after retry")
            turn_text, streamed_tool_calls, usage_obj, finish_reason = await _consume_stream(
                response_stream, on_text
            )
            usage = _normalize_usage(usage_obj)

            total_prompt_tokens += usage["prompt_tokens"]
            total_completion_tokens += usage["completion_tokens"]
            total_cost += _completion_cost(
                model=model,
                prompt_tokens=usage["prompt_tokens"],
                completion_tokens=usage["completion_tokens"],
            )

            normalized_calls = _normalize_tool_calls(
                streamed_tool_calls,
                turn_index,
                log_context=log_context,
                logger=run_log,
            )
            run_log.info(
                "llm response",
                model=model,
                turn=turn_index,
                prompt_tokens=usage["prompt_tokens"],
                completion_tokens=usage["completion_tokens"],
                total_prompt_tokens=total_prompt_tokens,
                total_completion_tokens=total_completion_tokens,
                tool_calls=len(normalized_calls),
                tool_rounds=tool_rounds,
                has_text=turn_text is not None,
                **log_context,
            )

            if not normalized_calls:
                if finish_reason == "max_tokens":
                    run_log.warning(
                        "response truncated by max_tokens",
                        model=model,
                        turn=turn_index,
                        **log_context,
                    )
                    turn_text = (turn_text or "") + (
                        "\n\n(Response was truncated due to output token limit)"
                    )
                elif finish_reason == "incomplete":
                    turn_text = (turn_text or "") + "\n\n(Response may be incomplete)"
                turns.append(Turn(text=turn_text, tool_calls=[], usage=usage))
                final_text = turn_text or final_text
                clean_exit = True
                break

            tool_rounds += 1
            if is_claude_code and tool_rounds > _CLAUDE_CODE_MAX_TOOL_ROUNDS:
                run_log.warning(
                    "tool round limit reached",
                    model=model,
                    turn=turn_index,
                    tool_rounds=tool_rounds,
                    max_tool_rounds=_CLAUDE_CODE_MAX_TOOL_ROUNDS,
                    **log_context,
                )
                turn_text = (turn_text or "") + "\n\n(Stopped: reached tool round limit)"
                turns.append(Turn(text=turn_text, tool_calls=[], usage=usage))
                final_text = turn_text or final_text
                clean_exit = True
                break

            assistant_tool_calls = []
            for call in normalized_calls:
                tool_call_payload: dict[str, Any] = {
                    "id": call["id"],
                    "type": "function",
                    "function": {"name": call["name"], "arguments": call["arguments"]},
                }
                thought_signature = str(call.get("thought_signature", "")).strip()
                if thought_signature:
                    tool_call_payload["extra_content"] = {
                        "google": {"thought_signature": thought_signature}
                    }
                assistant_tool_calls.append(tool_call_payload)
            assistant_message: dict[str, Any] = {
                "role": "assistant",
                "tool_calls": assistant_tool_calls,
            }
            if turn_text is not None:
                assistant_message["content"] = turn_text
            messages.append(assistant_message)

            executed_calls = await _execute_tool_calls_with_policy(
                calls=normalized_calls,
                tools_by_name=tools_by_name,
                on_tool_start=on_tool_start,
                on_tool_end=on_tool_end,
                log_context=log_context,
                logger=run_log,
                enforce_post_delegate_policy=agent_kind == "primary",
                delegation_handoff_active=delegation_handoff_active,
            )
            if agent_kind == "primary" and not delegation_handoff_active:
                delegation_handoff_active = any(
                    _tool_result_indicates_delegation_handoff(executed)
                    for executed in executed_calls
                )

            turn_tool_calls: list[dict] = []
            for executed in executed_calls:
                turn_tool_calls.append(
                    {
                        "name": executed["name"],
                        "args": executed["args"],
                        "result": executed["result"],
                    }
                )
                tool_result_for_model = _compact_tool_result(executed["result"])
                tool_images = executed.get("images")
                if tool_images and _supports_vision(model):
                    content: str | list[dict] = [
                        {"type": "text", "text": tool_result_for_model},
                    ]
                    for img in tool_images:
                        content.append(
                            {
                                "type": "image_url",
                                "image_url": {"url": f"data:{img['mime']};base64,{img['data']}"},
                            }
                        )
                else:
                    content = tool_result_for_model
                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": executed["id"],
                        "name": executed["name"],
                        "content": content,
                    }
                )

            turns.append(Turn(text=turn_text, tool_calls=turn_tool_calls, usage=usage))
            final_text = turn_text or final_text
        except asyncio.CancelledError:
            run_log.info("agent cancelled", turn=turn_index, **log_context)
            raise

    if not clean_exit and max_turns > 0:
        # Loop exhausted — make one final call without tools to get a summary
        try:
            effective_system = system
            if isinstance(provider, OAuthStreamProvider):
                effective_system = provider.effective_system(system)
            messages = await _compact_context(
                model,
                effective_system,
                messages,
                provider,
                enabled=compaction_enabled,
                threshold_ratio=compaction_threshold,
                keep_recent_messages=compaction_keep_recent_messages,
                log_context=log_context,
                logger=run_log,
            )
            messages = _trim_to_context_window(
                model,
                effective_system,
                messages,
                log_context=log_context,
                logger=run_log,
            )
            summary_stream = await provider.stream_completion(
                model=model,
                messages=_messages_for_request(effective_system, messages),
                tools=[],
                tool_choice=None,
                max_tokens=_max_output_tokens(model) if is_claude_code else None,
                reasoning_effort=reasoning_effort,
            )
            summary_text, _, summary_usage, _ = await _consume_stream(summary_stream, on_text)
            if summary_text:
                final_text = summary_text
                usage = _normalize_usage(summary_usage)
                total_prompt_tokens += usage["prompt_tokens"]
                total_completion_tokens += usage["completion_tokens"]
                turns.append(Turn(text=summary_text, tool_calls=[], usage=usage))
        except Exception as exc:
            run_log.warning(
                "summary call after loop exhaustion failed",
                error=str(exc),
                **log_context,
            )

        if not final_text or not final_text.strip():
            final_text = "(Stopped: reached maximum turns without producing a response)"

    return AgentResult(
        text=final_text,
        turns=turns,
        total_usage={
            "prompt_tokens": total_prompt_tokens,
            "completion_tokens": total_completion_tokens,
            "cost": total_cost,
        },
    )


def _tool_to_schema(tool: Tool) -> dict[str, dict[str, Any] | str]:
    return {
        "type": "function",
        "function": {
            "name": tool.name,
            "description": tool.description,
            "parameters": tool.parameters,
        },
    }


def _messages_for_request(system: str, messages: list[dict]) -> list[dict]:
    if not system:
        return list(messages)
    return [{"role": "system", "content": system}, *messages]


def _max_input_tokens(model: str) -> int:
    litellm_model = model.split("/", 1)[-1] if "/" in model else model
    try:
        model_info = litellm.get_model_info(litellm_model)
        max_input_tokens = int((model_info or {}).get("max_input_tokens") or 0)
    except Exception:
        max_input_tokens = 0

    if max_input_tokens <= 0:
        if "claude" in model.lower():
            max_input_tokens = 200_000
        elif "gpt-4" in model.lower():
            max_input_tokens = 128_000

    return max_input_tokens


def _count_tokens(model: str, system: str, messages: list[dict]) -> int:
    litellm_model = model.split("/", 1)[-1] if "/" in model else model
    try:
        return int(
            litellm.token_counter(
                model=litellm_model, messages=_messages_for_request(system, messages)
            )
            or 0
        )
    except Exception:
        # Conservative fallback: ~500 tokens per message
        return len(_messages_for_request(system, messages)) * 500


def _content_to_text(content: Any) -> str:
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts: list[str] = []
        for chunk in content:
            if isinstance(chunk, dict):
                text = chunk.get("text")
                if isinstance(text, str) and text:
                    parts.append(text)
        return "\n".join(parts)
    return ""


def _extract_tag_paths(text: str, tag: str) -> set[str]:
    if not text:
        return set()
    match = re.search(rf"<{tag}>(.*?)</{tag}>", text, flags=re.DOTALL)
    if not match:
        return set()
    paths = [line.strip() for line in match.group(1).splitlines()]
    return {path for path in paths if path}


def _extract_file_tracking(messages: list[dict]) -> tuple[set[str], set[str]]:
    read_files: set[str] = set()
    modified_files: set[str] = set()

    for message in messages:
        role = message.get("role")
        if role == "assistant":
            for call in message.get("tool_calls") or []:
                function = call.get("function") or {}
                tool_name = str(function.get("name") or "")
                raw_args = function.get("arguments")
                if not isinstance(raw_args, str):
                    continue
                try:
                    parsed_args = json.loads(raw_args) if raw_args.strip() else {}
                except json.JSONDecodeError:
                    continue
                if not isinstance(parsed_args, dict):
                    continue
                path = str(parsed_args.get("path") or "").strip()
                if not path:
                    continue
                if tool_name == "read_file":
                    read_files.add(path)
                elif tool_name in {"edit_file", "write_file"}:
                    modified_files.add(path)

        text = _content_to_text(message.get("content"))
        read_files.update(_extract_tag_paths(text, "read-files"))
        modified_files.update(_extract_tag_paths(text, "modified-files"))

    return read_files, modified_files


def _serialize_conversation(messages: list[dict]) -> str:
    lines: list[str] = []
    for message in messages:
        role = message.get("role")
        if role == "user":
            lines.append(f"[User]: {_content_to_text(message.get('content'))}")
            continue
        if role == "assistant":
            text = _content_to_text(message.get("content"))
            if text:
                lines.append(f"[Assistant]: {text}")
            tool_calls = message.get("tool_calls") or []
            if tool_calls:
                calls: list[str] = []
                for call in tool_calls:
                    function = call.get("function") or {}
                    name = function.get("name") or "unknown"
                    args = function.get("arguments") or "{}"
                    calls.append(f"{name}({args})")
                lines.append("[Assistant tool calls]: " + "; ".join(calls))
            continue
        if role == "tool":
            name = message.get("name") or "tool"
            lines.append(f"[Tool result:{name}]: {_content_to_text(message.get('content'))}")
            continue

        lines.append(f"[{role or 'message'}]: {_content_to_text(message.get('content'))}")

    return "\n".join(lines)


def _is_compaction_summary_message(message: dict[str, Any]) -> bool:
    if message.get("role") != "assistant":
        return False
    content = _content_to_text(message.get("content"))
    return content.strip().startswith(_COMPACTION_MARKER)


async def _compact_context(
    model: str,
    system: str,
    messages: list[dict],
    provider: StreamProvider,
    *,
    enabled: bool = True,
    threshold_ratio: float = 0.8,
    keep_recent_messages: int = _COMPACTION_KEEP_RECENT_MESSAGES,
    log_context: dict[str, Any] | None = None,
    logger: Any | None = None,
) -> list[dict]:
    active_log = logger or log

    if not enabled:
        return messages

    max_input_tokens = _max_input_tokens(model)
    if max_input_tokens <= 0:
        return messages

    ratio = min(max(float(threshold_ratio), 0.1), 0.95)
    threshold = int(max_input_tokens * ratio)
    current_tokens = _count_tokens(model, system, messages)
    if current_tokens <= threshold:
        return messages

    turn_indices = [
        idx for idx, msg in enumerate(messages) if msg.get("role") in {"user", "assistant"}
    ]
    keep_recent = max(2, int(keep_recent_messages))
    if len(turn_indices) <= keep_recent:
        return messages

    first_kept_idx = turn_indices[-keep_recent]
    messages_to_summarize = messages[:first_kept_idx]
    kept_messages = messages[first_kept_idx:]

    # Guardrail: avoid recursively summarizing existing summary blocks.
    messages_to_summarize = [
        m for m in messages_to_summarize if not _is_compaction_summary_message(m)
    ]
    if not messages_to_summarize:
        return messages

    active_log.info(
        "context compaction attempt",
        model=model,
        current_tokens=current_tokens,
        threshold_tokens=threshold,
        threshold_ratio=ratio,
        keep_recent_messages=keep_recent,
        summarize_messages=len(messages_to_summarize),
        kept_messages=len(kept_messages),
        **(log_context or {}),
    )

    conversation_text = _serialize_conversation(messages_to_summarize)
    if len(conversation_text) > _COMPACTION_MAX_SOURCE_CHARS:
        conversation_text = (
            conversation_text[:_COMPACTION_MAX_SOURCE_CHARS]
            + "\n\n[conversation truncated before summary generation]"
        )

    read_files, modified_files = _extract_file_tracking(messages)
    read_lines = "\n".join(sorted(read_files))
    modified_lines = "\n".join(sorted(modified_files))

    summary_prompt = (
        "Conversation transcript to summarize:\n\n"
        f"{conversation_text}\n\n"
        "Current cumulative file tracking:\n"
        f"<read-files>\n{read_lines}\n</read-files>\n"
        f"<modified-files>\n{modified_lines}\n</modified-files>"
    )

    try:
        summary_stream = await provider.stream_completion(
            model=model,
            messages=[
                {"role": "system", "content": _COMPACTION_SYSTEM_PROMPT},
                {"role": "user", "content": summary_prompt},
            ],
            tools=[],
            tool_choice=None,
            max_tokens=_max_output_tokens(model)
            if model.lower().startswith("claude-code/")
            else None,
        )
        summary_text, _, _, _ = await _consume_stream(summary_stream, None)
    except Exception as exc:
        active_log.warning(
            "context compaction failed",
            model=model,
            error=str(exc),
            current_tokens=current_tokens,
            threshold_tokens=threshold,
            **(log_context or {}),
        )
        return messages

    if not summary_text or not summary_text.strip():
        active_log.warning(
            "context compaction skipped",
            model=model,
            reason="empty_summary",
            current_tokens=current_tokens,
            threshold_tokens=threshold,
            **(log_context or {}),
        )
        return messages

    summary_content = summary_text.strip()
    if "<read-files>" not in summary_content:
        summary_content += f"\n\n<read-files>\n{read_lines}\n</read-files>"
    if "<modified-files>" not in summary_content:
        summary_content += f"\n\n<modified-files>\n{modified_lines}\n</modified-files>"

    summary_message = {
        "role": "assistant",
        "content": f"{_COMPACTION_MARKER}\n{summary_content}",
    }
    compacted_messages = [summary_message, *kept_messages]

    compacted_tokens = _count_tokens(model, system, compacted_messages)
    saved_tokens = max(0, current_tokens - compacted_tokens)
    active_log.info(
        "context compacted",
        model=model,
        original_tokens=current_tokens,
        compacted_tokens=compacted_tokens,
        saved_tokens=saved_tokens,
        summarized_messages=len(messages_to_summarize),
        kept_messages=len(kept_messages),
        **(log_context or {}),
    )
    return compacted_messages


def _trim_to_context_window(
    model: str,
    system: str,
    messages: list[dict],
    *,
    log_context: dict[str, Any] | None = None,
    logger: Any | None = None,
) -> list[dict]:
    active_log = logger or log

    min_turn_messages = 8

    # Strip custom routing prefixes (e.g. "claude-code/claude-haiku-4-5" → "claude-haiku-4-5")
    # so litellm can resolve model metadata and count tokens correctly.
    litellm_model = model.split("/", 1)[-1] if "/" in model else model

    try:
        model_info = litellm.get_model_info(litellm_model)
        max_input_tokens = int((model_info or {}).get("max_input_tokens") or 0)
    except Exception:
        max_input_tokens = 0

    if max_input_tokens <= 0:
        if "claude" in model.lower():
            max_input_tokens = 200_000
        elif "gpt-4" in model.lower():
            max_input_tokens = 128_000
        else:
            return messages

    threshold = max_input_tokens * 0.8
    request_messages = _messages_for_request(system, messages)
    try:
        current_tokens = int(
            litellm.token_counter(model=litellm_model, messages=request_messages) or 0
        )
    except Exception:
        # Conservative fallback: ~500 tokens per message
        current_tokens = len(request_messages) * 500

    if current_tokens <= threshold:
        return messages

    original_tokens = current_tokens
    dropped_messages = 0

    while current_tokens > threshold:
        turn_indices = [
            idx
            for idx, message in enumerate(messages)
            if message.get("role") in {"user", "assistant"}
        ]
        if len(turn_indices) - 2 < min_turn_messages:
            break

        next_turn_index = turn_indices[2]
        dropped_messages += next_turn_index
        del messages[:next_turn_index]

        try:
            current_tokens = int(
                litellm.token_counter(
                    model=litellm_model,
                    messages=_messages_for_request(system, messages),
                )
                or 0
            )
        except Exception:
            current_tokens = len(_messages_for_request(system, messages)) * 500

    if dropped_messages > 0:
        active_log.warning(
            "context trimmed",
            model=model,
            original_tokens=original_tokens,
            trimmed_tokens=current_tokens,
            messages_dropped=dropped_messages,
            **(log_context or {}),
        )

    return messages


async def _consume_stream(
    stream: Any, on_text: Callable[[str], None] | None
) -> tuple[str | None, list[dict[str, str]], Any, str | None]:
    text_parts: list[str] = []
    tool_calls: list[dict[str, str]] = []
    usage_obj: Any = None
    finish_reason: str | None = None

    async for chunk in stream:
        chunk_usage = getattr(chunk, "usage", None)
        if chunk_usage is not None:
            usage_obj = chunk_usage

        choices = getattr(chunk, "choices", None)
        if not choices:
            continue

        choice = choices[0]
        chunk_finish_reason = getattr(choice, "finish_reason", None)
        if chunk_finish_reason is not None:
            finish_reason = chunk_finish_reason

        delta = getattr(choice, "delta", None)
        if delta is None:
            continue

        content = getattr(delta, "content", None)
        if content:
            text_parts.append(content)
            if on_text is not None:
                on_text(content)

        delta_tool_calls = getattr(delta, "tool_calls", None) or []
        for delta_tool_call in delta_tool_calls:
            index = getattr(delta_tool_call, "index", None)
            if index is None or index < 0:
                continue

            while len(tool_calls) <= index:
                tool_calls.append(
                    {
                        "id": "",
                        "name": "",
                        "arguments": "",
                        "thought_signature": "",
                    }
                )

            current = tool_calls[index]
            tool_call_id = getattr(delta_tool_call, "id", None)
            if tool_call_id:
                current["id"] = tool_call_id

            thought_signature = getattr(delta_tool_call, "thought_signature", None)
            if thought_signature:
                current["thought_signature"] = thought_signature

            function = getattr(delta_tool_call, "function", None)
            if function is None:
                continue

            function_name = getattr(function, "name", None)
            if function_name:
                current["name"] = function_name

            function_arguments = getattr(function, "arguments", None)
            if function_arguments:
                current["arguments"] += function_arguments

    text = "".join(text_parts)
    if finish_reason is None:
        finish_reason = "incomplete"
    return (text if text else None), tool_calls, usage_obj, finish_reason


def _normalize_tool_calls(
    tool_calls: list[dict[str, str]],
    turn_index: int,
    *,
    log_context: dict[str, Any] | None = None,
    logger: Any | None = None,
) -> list[dict[str, str]]:
    active_log = logger or log
    normalized: list[dict[str, str]] = []
    for index, tool_call in enumerate(tool_calls):
        name = tool_call.get("name", "").strip()
        if not name:
            has_thought = bool(tool_call.get("thought_signature", "").strip())
            has_args = bool(tool_call.get("arguments", "").strip())
            has_id = bool(tool_call.get("id", "").strip())
            active_log.warning(
                "skipping tool call with empty name",
                turn=turn_index,
                index=index,
                has_thought_signature=has_thought,
                has_arguments=has_args,
                has_id=has_id,
                **(log_context or {}),
            )
            continue
        normalized_item = {
            "id": tool_call.get("id") or f"tool_call_{turn_index}_{index}",
            "name": name,
            "arguments": tool_call.get("arguments", ""),
        }
        thought_signature = tool_call.get("thought_signature", "").strip()
        if thought_signature:
            normalized_item["thought_signature"] = thought_signature
        normalized.append(normalized_item)
    return normalized


def _is_safe_parallel_tool(name: str) -> bool:
    return name in _SAFE_PARALLEL_TOOLS


def _tool_call_fingerprint(call: dict[str, str]) -> str:
    name = call.get("name", "")
    arguments = call.get("arguments", "").strip()
    return f"{name}|{arguments}"


def _is_orchestration_tool(name: str) -> bool:
    return name in _ORCHESTRATION_TOOLS


def _tool_result_indicates_delegation_handoff(executed: dict[str, Any]) -> bool:
    details = executed.get("details")
    if not isinstance(details, dict):
        return False
    if executed.get("status") == "error":
        return False
    return details.get("handoff") is True


def _post_delegate_policy_tool_result(
    *,
    call: dict[str, str],
    on_tool_start: Callable[[str, dict], None] | None,
    on_tool_end: Callable[[str, str], None] | None,
) -> dict[str, Any]:
    tool_name = call.get("name", "unknown")
    try:
        parsed_args = _parse_tool_arguments(call.get("arguments", ""))
    except ValueError:
        parsed_args = {}

    result = (
        "Primary agent already delegated background work in this request. "
        "Only orchestration tools are allowed now: delegate_task, list_jobs, "
        "cancel_job, retry_job, redirect_job."
    )
    if on_tool_start is not None:
        on_tool_start(tool_name, parsed_args)
    if on_tool_end is not None:
        on_tool_end(tool_name, result)

    return {
        "id": call.get("id", ""),
        "name": tool_name,
        "args": parsed_args,
        "result": result,
        "status": "error",
        "retryable": False,
        "details": {"kind": "post_delegate_policy", "handoff_active": True},
    }


def _retry_guard_tool_result(
    *,
    call: dict[str, str],
    on_tool_start: Callable[[str, dict], None] | None,
    on_tool_end: Callable[[str, str], None] | None,
) -> dict[str, Any]:
    tool_name = call.get("name", "unknown")
    try:
        parsed_args = _parse_tool_arguments(call.get("arguments", ""))
    except ValueError:
        parsed_args = {}

    result = (
        f"Tool '{tool_name}' already failed with the same arguments in this turn. "
        "Try a different tool or change the arguments."
    )
    if on_tool_start is not None:
        on_tool_start(tool_name, parsed_args)
    if on_tool_end is not None:
        on_tool_end(tool_name, result)

    return {
        "id": call.get("id", ""),
        "name": tool_name,
        "args": parsed_args,
        "result": result,
        "status": "error",
        "retryable": False,
        "details": {"retry_guard": True},
    }


def _normalize_tool_execution_result(execution_result: Any) -> tuple[str, list[dict] | None, dict]:
    images: list[dict] | None = None
    details: dict[str, Any] = {}

    if isinstance(execution_result, dict):
        maybe_images = execution_result.get("images")
        if isinstance(maybe_images, list):
            images = maybe_images

        maybe_details = execution_result.get("details")
        if isinstance(maybe_details, dict):
            details.update(maybe_details)

        status = execution_result.get("status")
        if status in {"success", "error", "partial"}:
            details["status"] = status

        retryable = execution_result.get("retryable")
        if isinstance(retryable, bool):
            details["retryable"] = retryable

        if "text" in execution_result:
            text = str(execution_result.get("text", ""))
        else:
            text = str(execution_result)
        return text, images, details

    return str(execution_result), images, details


async def _execute_tool_calls_with_policy(
    *,
    calls: list[dict[str, str]],
    tools_by_name: dict[str, Tool],
    on_tool_start: Callable[[str, dict], None] | None,
    on_tool_end: Callable[[str, str], None] | None,
    log_context: dict[str, Any] | None = None,
    logger: Any | None = None,
    enforce_post_delegate_policy: bool = False,
    delegation_handoff_active: bool = False,
) -> list[dict[str, Any]]:
    """Execute tool calls with deterministic scheduling.

    - Serial by default
    - Parallel only for contiguous read-only safe tools
    - Duplicate failed calls in the same turn are guarded
    - Optional post-delegation policy can block non-orchestration tools
    """
    executed_calls: list[dict[str, Any]] = []
    failed_fingerprints: set[str] = set()
    orchestration_only = bool(enforce_post_delegate_policy and delegation_handoff_active)

    idx = 0
    while idx < len(calls):
        call = calls[idx]
        call_name = call.get("name", "")

        if orchestration_only and not _is_orchestration_tool(call_name):
            executed_calls.append(
                _post_delegate_policy_tool_result(
                    call=call,
                    on_tool_start=on_tool_start,
                    on_tool_end=on_tool_end,
                )
            )
            idx += 1
            continue

        if not _is_safe_parallel_tool(call_name):
            fingerprint = _tool_call_fingerprint(call)
            if fingerprint in failed_fingerprints:
                executed_calls.append(
                    _retry_guard_tool_result(
                        call=call,
                        on_tool_start=on_tool_start,
                        on_tool_end=on_tool_end,
                    )
                )
                idx += 1
                continue

            executed = await _execute_tool_call_with_timeout(
                call=call,
                tools_by_name=tools_by_name,
                on_tool_start=on_tool_start,
                on_tool_end=on_tool_end,
                log_context=log_context,
                logger=logger,
            )
            executed_calls.append(executed)
            if executed.get("status") == "error":
                failed_fingerprints.add(fingerprint)
            if enforce_post_delegate_policy and _tool_result_indicates_delegation_handoff(executed):
                orchestration_only = True
            idx += 1
            continue

        safe_batch: list[dict[str, str]] = []
        while idx < len(calls) and _is_safe_parallel_tool(calls[idx].get("name", "")):
            safe_batch.append(calls[idx])
            idx += 1

        blocked_results: dict[int, dict[str, Any]] = {}
        pending_calls: list[tuple[int, dict[str, str], str]] = []

        for position, batch_call in enumerate(safe_batch):
            fingerprint = _tool_call_fingerprint(batch_call)
            if fingerprint in failed_fingerprints:
                blocked_results[position] = _retry_guard_tool_result(
                    call=batch_call,
                    on_tool_start=on_tool_start,
                    on_tool_end=on_tool_end,
                )
            else:
                pending_calls.append((position, batch_call, fingerprint))

        batch_results: dict[int, dict[str, Any]] = dict(blocked_results)
        if pending_calls:
            executed_pending = await asyncio.gather(
                *[
                    _execute_tool_call_with_timeout(
                        call=batch_call,
                        tools_by_name=tools_by_name,
                        on_tool_start=on_tool_start,
                        on_tool_end=on_tool_end,
                        log_context=log_context,
                        logger=logger,
                    )
                    for _position, batch_call, _fingerprint in pending_calls
                ]
            )
            for (position, _batch_call, fingerprint), executed in zip(
                pending_calls, executed_pending, strict=True
            ):
                batch_results[position] = executed
                if executed.get("status") == "error":
                    failed_fingerprints.add(fingerprint)
                if enforce_post_delegate_policy and _tool_result_indicates_delegation_handoff(
                    executed
                ):
                    orchestration_only = True

        for position in range(len(safe_batch)):
            executed_calls.append(batch_results[position])

    return executed_calls


async def _execute_tool_call(
    *,
    call: dict[str, str],
    tools_by_name: dict[str, Tool],
    on_tool_start: Callable[[str, dict], None] | None,
    on_tool_end: Callable[[str, str], None] | None,
    log_context: dict[str, Any] | None = None,
    logger: Any | None = None,
) -> dict[str, Any]:
    active_log = logger or log
    tool_name = call["name"]

    try:
        parsed_args = _parse_tool_arguments(call["arguments"])
    except ValueError as error:
        parsed_args = {}
        result = str(error)
        if on_tool_start is not None:
            on_tool_start(tool_name, parsed_args)
        if on_tool_end is not None:
            on_tool_end(tool_name, result)
        return {
            "id": call["id"],
            "name": tool_name,
            "args": parsed_args,
            "result": result,
            "status": "error",
            "retryable": False,
            "details": {"kind": "argument_parse"},
        }

    if on_tool_start is not None:
        on_tool_start(tool_name, parsed_args)

    tool = tools_by_name.get(tool_name)
    if tool is None:
        active_log.warning("unknown tool", tool=tool_name, **(log_context or {}))
        result = f"Unknown tool name: {tool_name}"
        if on_tool_end is not None:
            on_tool_end(tool_name, result)
        return {
            "id": call["id"],
            "name": tool_name,
            "args": parsed_args,
            "result": result,
            "status": "error",
            "retryable": False,
            "details": {"kind": "unknown_tool"},
        }

    active_log.info(
        "tool executing",
        tool=tool_name,
        **_log_args(parsed_args),
        **(log_context or {}),
    )
    images: list[dict] | None = None
    details: dict[str, Any] = {}
    status = "success"
    retryable = False

    try:
        execution_result = await tool.execute(**parsed_args)
        result, images, normalized_details = _normalize_tool_execution_result(execution_result)
        details.update(normalized_details)
        status = str(details.pop("status", status))
        retryable = bool(details.pop("retryable", retryable))
    except TypeError as error:
        error_msg = str(error)
        status = "error"
        retryable = True
        if "required" in error_msg and "argument" in error_msg:
            active_log.warning(
                "tool missing required args",
                tool=tool_name,
                error=error_msg,
                **(log_context or {}),
            )
            required = _get_required_params(tool)
            details["kind"] = "missing_required_args"
            details["required"] = required
            result = (
                f"Error: missing required arguments for '{tool_name}'. "
                f"Required parameters: {required}. "
                f"You provided: {list(parsed_args.keys()) or '(none)'}. "
                f"Please retry with the required arguments."
            )
        else:
            active_log.error(
                "tool failed",
                tool=tool_name,
                error=error_msg,
                **(log_context or {}),
            )
            details["kind"] = "type_error"
            result = f"Tool execution error: {error}"
    except Exception as error:  # pragma: no cover - defensive path for arbitrary tool errors
        status = "error"
        retryable = True
        details["kind"] = "execution_exception"
        active_log.error(
            "tool failed",
            tool=tool_name,
            error=str(error),
            **(log_context or {}),
        )
        result = f"Tool execution error: {error}"

    if on_tool_end is not None:
        on_tool_end(tool_name, result)

    executed: dict[str, Any] = {
        "id": call["id"],
        "name": tool_name,
        "args": parsed_args,
        "result": result,
        "status": status,
        "retryable": retryable,
    }
    if details:
        executed["details"] = details
    if images:
        executed["images"] = images
    return executed


async def _execute_tool_call_with_timeout(
    *,
    call: dict[str, str],
    tools_by_name: dict[str, Tool],
    on_tool_start: Callable[[str, dict], None] | None,
    on_tool_end: Callable[[str, str], None] | None,
    log_context: dict[str, Any] | None = None,
    logger: Any | None = None,
) -> dict[str, Any]:
    active_log = logger or log

    tool_name = call.get("name", "unknown")
    tool = tools_by_name.get(tool_name)
    timeout = (tool.timeout_seconds if tool else None) or _TOOL_TIMEOUT_SECONDS

    try:
        return await asyncio.wait_for(
            _execute_tool_call(
                call=call,
                tools_by_name=tools_by_name,
                on_tool_start=on_tool_start,
                on_tool_end=on_tool_end,
                log_context=log_context,
                logger=logger,
            ),
            timeout=timeout,
        )
    except TimeoutError:
        active_log.warning(
            "tool timed out",
            tool=tool_name,
            timeout=timeout,
            **(log_context or {}),
        )
        result = f"Tool '{tool_name}' timed out after {int(timeout)} seconds"
        if on_tool_end is not None:
            on_tool_end(tool_name, result)
        return {
            "id": call["id"],
            "name": tool_name,
            "args": {},
            "result": result,
            "status": "error",
            "retryable": True,
            "details": {"kind": "timeout", "seconds": int(timeout)},
        }


def _get_required_params(tool: Tool) -> list[str]:
    props = tool.parameters.get("properties", {})
    required = tool.parameters.get("required", [])
    if required:
        return list(required)
    return [k for k, v in props.items() if not v.get("default")]


def _log_args(args: dict) -> dict[str, str]:
    """Extract loggable fields from tool args (paths, commands, queries — not content)."""
    out: dict[str, str] = {}
    for key in ("path", "command", "query", "pattern", "key", "name", "url", "text"):
        if key in args:
            value = str(args[key])
            out[key] = value[:120] if len(value) > 120 else value
    return out


def _compact_tool_result(result: str) -> str:
    if len(result) <= _MAX_TOOL_RESULT_CHARS:
        return result
    omitted = len(result) - _MAX_TOOL_RESULT_CHARS
    return (
        result[:_MAX_TOOL_RESULT_CHARS]
        + f"\n\n[truncated tool output: omitted {omitted} characters]"
    )


def _parse_tool_arguments(arguments: str) -> dict:
    raw = arguments.strip()
    if not raw:
        return {}

    try:
        parsed = json.loads(raw)
    except json.JSONDecodeError as error:
        raise ValueError(f"Tool argument JSON parse error: {error.msg}") from error

    if not isinstance(parsed, dict):
        raise ValueError("Tool argument JSON parse error: expected a JSON object")

    return parsed


def _normalize_usage(usage_obj: Any) -> dict[str, int]:
    prompt_tokens = _get_usage_value(usage_obj, "prompt_tokens")
    completion_tokens = _get_usage_value(usage_obj, "completion_tokens")
    return {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}


def _get_usage_value(usage_obj: Any, field: str) -> int:
    if usage_obj is None:
        return 0

    value: Any
    if isinstance(usage_obj, dict):
        value = usage_obj.get(field, 0)
    else:
        value = getattr(usage_obj, field, 0)

    try:
        return int(value or 0)
    except (TypeError, ValueError):
        return 0


def _completion_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
    completion_cost_fn = getattr(litellm, "completion_cost", None)
    if not callable(completion_cost_fn):
        return 0.0

    try:
        cost = completion_cost_fn(
            model=model,
            prompt_tokens=prompt_tokens,
            completion_tokens=completion_tokens,
        )
    except Exception:
        return 0.0

    try:
        return float(cost)
    except (TypeError, ValueError):
        return 0.0
