"""Content tool-call salvage (docs/kernel_spec.md §5, native-tool-calls-invariant).

Server-side, parse-only recovery of tool calls the model emitted as assistant
*content* instead of native ``tool_calls`` — never taught to the model. The
kernel ships one built-in salvage (the Hermes ``<tool_call>{json}</tool_call>``
shape). A richer salvage (the legacy multi-format parser, which also handles
Gemma channel-style ``call:name{args}``) is injected by a peripheral as a
``content_salvage`` hook so this module stays boundary-clean.

Boundary: stdlib + core/ only.
"""
from __future__ import annotations

import json
import re
import secrets
import time
from collections.abc import Callable

from core._types import ToolCall

# A content_salvage hook: given assistant content, return the salvaged native
# calls and the content with the salvaged markup removed.
ContentSalvage = Callable[[str], "tuple[list[ToolCall], str]"]

_TOOL_CALL_MARKER = "<tool_call"

# The single permitted built-in content-XML salvage shape: a <tool_call> block
# whose body is a JSON object {"name": ..., "arguments": ...}.
_XML_TOOL_CALL_RE = re.compile(
    r"<tool_call>\s*(\{[\s\S]*?\})\s*</tool_call>", re.IGNORECASE
)


def normalize_call_ids(calls: list[ToolCall]) -> None:
    """Native history requires OpenAI-style ``call_*`` ids (xmltc_* etc. rewritten)."""
    for tc in calls:
        if not tc.id.startswith("call_"):
            tc.id = f"call_{int(time.time() * 1_000_000)}_{secrets.token_hex(4)}"


def salvage_xml_tool_calls(content: str) -> "tuple[list[ToolCall], str]":
    """Parse ``<tool_call>{json}</tool_call>`` blocks out of assistant content.

    Server-side salvage only — never taught to the model. Returns the salvaged
    native-shaped calls and the content with the blocks removed. Malformed
    blocks are ignored (the kernel loop surfaces errors; it does not repair).
    """
    calls: list[ToolCall] = []
    if _TOOL_CALL_MARKER not in (content or "").lower():
        return calls, content
    for m in _XML_TOOL_CALL_RE.finditer(content):
        try:
            obj = json.loads(m.group(1))
        except json.JSONDecodeError:
            continue
        name = str(obj.get("name") or "").strip()
        if not name:
            continue
        args = obj.get("arguments")
        args_str = args if isinstance(args, str) else json.dumps(args or {})
        calls.append(ToolCall(id="", name=name, arguments=args_str))
    if calls:
        content = _XML_TOOL_CALL_RE.sub("", content).strip()
        normalize_call_ids(calls)
    return calls, content


def resolve_content_salvage(
    content: str, hook: "ContentSalvage | None"
) -> "tuple[list[ToolCall], str]":
    """Salvage tool calls from assistant content.

    With a ``hook`` (a peripheral's richer parser) we call it unconditionally:
    non-XML formats such as Gemma channel-style ``call:name{args}`` carry no
    ``<tool_call`` marker, so gating on that marker would skip them (the cutover
    regression). Without a hook we keep the marker-gated built-in XML salvage.
    Salvaged ids are normalized to ``call_*`` regardless of source.
    """
    if hook is not None:
        calls, cleaned = hook(content)
        if calls:
            normalize_call_ids(calls)
            return calls, cleaned
        return [], content
    return salvage_xml_tool_calls(content)
