//! Gemini Native format conversion module.
//!
//! Converts Anthropic Messages requests to Gemini `generateContent` requests,
//! and Gemini `GenerateContentResponse` payloads back to Anthropic Messages
//! responses for Claude-compatible clients.

use super::gemini_schema::build_gemini_function_declaration;
use super::gemini_shadow::{GeminiAssistantTurn, GeminiShadowStore, GeminiToolCallMeta};
use crate::proxy::error::ProxyError;
use serde_json::{json, Map, Value};
use std::collections::{HashMap, HashSet};

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AnthropicToolSchemaHint {
    expected_keys: Vec<String>,
    required_keys: Vec<String>,
}

pub type AnthropicToolSchemaHints = HashMap<String, AnthropicToolSchemaHint>;

/// Prefix used for Anthropic-visible tool call ids that we synthesize when
/// Gemini's `functionCall` omits the `id` field (Gemini 2.x parallel calls
/// often do). The prefix is how downstream request-path code recognizes that
/// the id is not a real Gemini id and must be stripped before forwarding back
/// to Gemini as `functionResponse.id`.
pub(crate) const SYNTHESIZED_ID_PREFIX: &str = "gemini_synth_";

/// Generate a unique tool-call id that is safe to expose to Anthropic clients
/// but must not be sent upstream to Gemini. Uses UUID v4 simple encoding
/// (32 lowercase hex chars) so that any number of parallel calls in the same
/// response remain distinguishable.
pub(crate) fn synthesize_tool_call_id() -> String {
    format!("{SYNTHESIZED_ID_PREFIX}{}", uuid::Uuid::new_v4().simple())
}

/// Returns true if `id` was produced by [`synthesize_tool_call_id`] and
/// therefore must be stripped when building Gemini request bodies.
pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool {
    id.starts_with(SYNTHESIZED_ID_PREFIX)
}

/// Anthropic 请求 → Gemini 原生请求。
///
/// 转换工具库 API：当前无生产调用方（连通性检查不再发真实请求，曾是其唯一 crate 内
/// 消费者），但保留其转换逻辑与下方测试套件，供代理转换路径复用 / 未来接线。
#[allow(dead_code)]
pub fn anthropic_to_gemini(body: Value) -> Result<Value, ProxyError> {
    anthropic_to_gemini_with_shadow(body, None, None, None)
}

pub fn anthropic_to_gemini_with_shadow(
    body: Value,
    shadow_store: Option<&GeminiShadowStore>,
    provider_id: Option<&str>,
    session_id: Option<&str>,
) -> Result<Value, ProxyError> {
    let mut result = json!({});
    let shadow_turns = shadow_store
        .zip(provider_id)
        .zip(session_id)
        .and_then(|((store, provider_id), session_id)| store.get_session(provider_id, session_id))
        .map(|snapshot| snapshot.turns)
        .unwrap_or_default();

    let messages = body.get("messages").and_then(|value| value.as_array());

    let system_instruction = build_system_instruction(
        body.get("system"),
        messages.map(|messages| messages.as_slice()),
    )?;
    if let Some(system) = system_instruction {
        result["systemInstruction"] = system;
    }

    if let Some(messages) = messages {
        result["contents"] = json!(convert_messages_to_contents(messages, &shadow_turns)?);
    }

    if let Some(generation_config) = build_generation_config(&body) {
        result["generationConfig"] = generation_config;
    }

    if let Some(tools) = body.get("tools").and_then(|value| value.as_array()) {
        let function_declarations: Vec<Value> = tools
            .iter()
            .filter(|tool| tool.get("type").and_then(|value| value.as_str()) != Some("BatchTool"))
            .map(|tool| {
                build_gemini_function_declaration(
                    tool.get("name")
                        .and_then(|value| value.as_str())
                        .unwrap_or(""),
                    tool.get("description").and_then(|value| value.as_str()),
                    tool.get("input_schema")
                        .cloned()
                        .unwrap_or_else(|| json!({})),
                )
            })
            .collect();

        if !function_declarations.is_empty() {
            result["tools"] = json!([{ "functionDeclarations": function_declarations }]);
        }
    }

    if let Some(tool_config) = map_tool_choice(body.get("tool_choice"))? {
        result["toolConfig"] = tool_config;
    }

    Ok(result)
}

/// Convenience wrapper over [`gemini_to_anthropic_with_shadow_and_hints`]
/// with no shadow store or schema hints. Used by the shared
/// `ProviderAdapter::transform_response` path and by tests.
#[allow(dead_code)] // kept as public API for non-streaming transform paths
pub fn gemini_to_anthropic(body: Value) -> Result<Value, ProxyError> {
    gemini_to_anthropic_with_shadow(body, None, None, None)
}

/// Convenience wrapper for callers that have a shadow store but no tool
/// schema hints. Production call sites funnel through
/// [`gemini_to_anthropic_with_shadow_and_hints`] directly; this helper exists
/// for test ergonomics and future external callers.
#[allow(dead_code)] // kept as public API for shadow-only transform paths
pub fn gemini_to_anthropic_with_shadow(
    body: Value,
    shadow_store: Option<&GeminiShadowStore>,
    provider_id: Option<&str>,
    session_id: Option<&str>,
) -> Result<Value, ProxyError> {
    gemini_to_anthropic_with_shadow_and_hints(body, shadow_store, provider_id, session_id, None)
}

pub fn gemini_to_anthropic_with_shadow_and_hints(
    body: Value,
    shadow_store: Option<&GeminiShadowStore>,
    provider_id: Option<&str>,
    session_id: Option<&str>,
    tool_schema_hints: Option<&AnthropicToolSchemaHints>,
) -> Result<Value, ProxyError> {
    if let Some(block_reason) = body
        .get("promptFeedback")
        .and_then(|value| value.get("blockReason"))
        .and_then(|value| value.as_str())
    {
        let text = format!("Request blocked by Gemini safety filters: {block_reason}");
        return Ok(json!({
            "id": body.get("responseId").and_then(|value| value.as_str()).unwrap_or(""),
            "type": "message",
            "role": "assistant",
            "content": [{ "type": "text", "text": text }],
            "model": body.get("modelVersion").and_then(|value| value.as_str()).unwrap_or(""),
            "stop_reason": "refusal",
            "stop_sequence": Value::Null,
            "usage": build_anthropic_usage(body.get("usageMetadata"))
        }));
    }

    let candidate = body
        .get("candidates")
        .and_then(|value| value.as_array())
        .and_then(|value| value.first())
        .ok_or_else(|| {
            ProxyError::TransformError("No candidates in Gemini response".to_string())
        })?;

    let parts = candidate
        .get("content")
        .and_then(|value| value.get("parts"))
        .and_then(|value| value.as_array())
        .cloned()
        .unwrap_or_default();

    let mut rectified_parts = parts.clone();
    rectify_tool_call_parts(&mut rectified_parts, tool_schema_hints);

    // Pre-pass: for every `functionCall` that lacks an id (or carries an
    // empty-string id), synthesize one and write it back into
    // `rectified_parts`. Three independent readers — the
    // Anthropic-visible `content[tool_use]` block below, the shadow
    // store's `assistant_content` (cloned from `rectified_parts` further
    // down), and `extract_tool_call_meta(&rectified_parts)` that populates
    // `shadow_turn.tool_calls` — must all see the same id. Otherwise the
    // client would receive id A while the shadow stored id B, and the
    // next round's `tool_result(tool_use_id=A)` would fail to resolve
    // through `tool_name_by_id` (which is built from the shadow), raising
    // `Unable to resolve Gemini functionResponse.name`. Streaming path
    // already has this single-source-of-truth property via
    // `tool_call_snapshots`.
    for part in rectified_parts.iter_mut() {
        let Some(function_call) = part.get_mut("functionCall").and_then(|v| v.as_object_mut())
        else {
            continue;
        };
        let needs_synth = function_call
            .get("id")
            .and_then(|v| v.as_str())
            .map(|s| s.is_empty())
            .unwrap_or(true);
        if needs_synth {
            function_call.insert("id".to_string(), json!(synthesize_tool_call_id()));
        }
    }

    let mut content = Vec::new();
    let mut has_tool_use = false;

    for part in &rectified_parts {
        if part.get("thought").and_then(|value| value.as_bool()) == Some(true) {
            continue;
        }

        if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
            if !text.is_empty() {
                content.push(json!({
                    "type": "text",
                    "text": text
                }));
            }
            continue;
        }

        if let Some(function_call) = part.get("functionCall") {
            has_tool_use = true;
            let id = function_call
                .get("id")
                .and_then(|value| value.as_str())
                .filter(|s| !s.is_empty())
                .map(ToString::to_string)
                .unwrap_or_else(synthesize_tool_call_id);
            content.push(json!({
                "type": "tool_use",
                "id": id,
                "name": function_call.get("name").and_then(|value| value.as_str()).unwrap_or(""),
                "input": function_call.get("args").cloned().unwrap_or_else(|| json!({}))
            }));
        }
    }

    let stop_reason = map_finish_reason(
        candidate
            .get("finishReason")
            .and_then(|value| value.as_str()),
        has_tool_use,
    );

    let anthropic_response = json!({
        "id": body.get("responseId").and_then(|value| value.as_str()).unwrap_or(""),
        "type": "message",
        "role": "assistant",
        "content": content,
        "model": body.get("modelVersion").and_then(|value| value.as_str()).unwrap_or(""),
        "stop_reason": stop_reason,
        "stop_sequence": Value::Null,
        "usage": build_anthropic_usage(body.get("usageMetadata"))
    });

    if let (Some(store), Some(provider_id), Some(session_id), Some(content)) = (
        shadow_store,
        provider_id,
        session_id,
        candidate.get("content"),
    ) {
        let mut shadow_content = content.clone();
        if let Some(parts_value) = shadow_content.get_mut("parts") {
            *parts_value = json!(rectified_parts.clone());
        }
        store.record_assistant_turn(
            provider_id,
            session_id,
            shadow_content,
            extract_tool_call_meta(&rectified_parts),
        );
    }

    Ok(anthropic_response)
}

pub fn extract_gemini_model(body: &Value) -> Option<&str> {
    body.get("model").and_then(|value| value.as_str())
}

fn build_system_instruction(
    system: Option<&Value>,
    messages: Option<&[Value]>,
) -> Result<Option<Value>, ProxyError> {
    let mut texts = Vec::new();

    if let Some(system) = system {
        collect_system_texts(system, &mut texts)?;
    }

    if let Some(messages) = messages {
        for message in messages {
            if message.get("role").and_then(|value| value.as_str()) != Some("system") {
                continue;
            }
            if let Some(content) = message.get("content") {
                collect_system_texts(content, &mut texts)?;
            }
        }
    }

    if texts.is_empty() {
        return Ok(None);
    }

    Ok(Some(json!({
        "parts": [{ "text": texts.join("\n\n") }]
    })))
}

fn collect_system_texts(value: &Value, texts: &mut Vec<String>) -> Result<(), ProxyError> {
    if let Some(text) = value.as_str() {
        if !text.is_empty() {
            texts.push(text.to_string());
        }
        return Ok(());
    }

    let Some(blocks) = value.as_array() else {
        return Err(ProxyError::TransformError(
            "Anthropic system must be a string or an array".to_string(),
        ));
    };

    texts.extend(
        blocks
            .iter()
            .filter_map(|block| block.get("text").and_then(|value| value.as_str()))
            .filter(|text| !text.is_empty())
            .map(ToString::to_string),
    );

    Ok(())
}

fn build_generation_config(body: &Value) -> Option<Value> {
    let mut config = Map::new();

    if let Some(value) = body.get("max_tokens") {
        config.insert("maxOutputTokens".to_string(), value.clone());
    }
    if let Some(value) = body.get("temperature") {
        config.insert("temperature".to_string(), value.clone());
    }
    if let Some(value) = body.get("top_p") {
        config.insert("topP".to_string(), value.clone());
    }
    if let Some(value) = body.get("stop_sequences") {
        config.insert("stopSequences".to_string(), value.clone());
    }

    if config.is_empty() {
        None
    } else {
        Some(Value::Object(config))
    }
}

fn convert_messages_to_contents(
    messages: &[Value],
    shadow_turns: &[GeminiAssistantTurn],
) -> Result<Vec<Value>, ProxyError> {
    let mut contents = Vec::new();
    let mut used_shadow_indices = HashSet::new();
    let total_assistant_messages = messages
        .iter()
        .filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("assistant"))
        .count();
    let effective_shadow_turns = if shadow_turns.len() > total_assistant_messages {
        &shadow_turns[shadow_turns.len() - total_assistant_messages..]
    } else {
        shadow_turns
    };

    // Build tool name and thought_signature maps from shadow store.
    // These are used to resolve tool_result→functionResponse names and to
    // attach thought signatures when replaying tool_use→functionCall.
    let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns);
    let mut thought_signature_by_id = build_thought_signature_map_from_shadow_turns(shadow_turns);

    // Pre-scan all assistant messages in the request body to seed
    // tool_name_by_id with every tool_use id mentioned in the conversation
    // history.  This ensures tool_result blocks can always resolve their
    // function name even when the shadow store has aged out the relevant
    // turn (e.g. long conversations, session restarts, or concurrent
    // session churn).
    for message in messages {
        if message.get("role").and_then(|v| v.as_str()) != Some("assistant") {
            continue;
        }
        if let Some(blocks) = message.get("content").and_then(|c| c.as_array()) {
            for block in blocks {
                if block.get("type").and_then(|v| v.as_str()) != Some("tool_use") {
                    continue;
                }
                let id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
                let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
                if !id.is_empty() && !name.is_empty() {
                    tool_name_by_id
                        .entry(id.to_string())
                        .or_insert_with(|| name.to_string());
                }
            }
        }
    }

    let shadow_start_index = total_assistant_messages.saturating_sub(effective_shadow_turns.len());
    let mut assistant_seen_index = 0usize;

    for message in messages {
        let role = message
            .get("role")
            .and_then(|value| value.as_str())
            .unwrap_or("user");
        if role == "system" {
            continue;
        }

        let gemini_role = if role == "assistant" { "model" } else { "user" };

        let parts = if role == "assistant" {
            let positional_shadow_index = assistant_seen_index
                .checked_sub(shadow_start_index)
                .filter(|index| *index < effective_shadow_turns.len())
                .filter(|index| !used_shadow_indices.contains(index));
            let tool_use_match_index = find_matching_shadow_turn_for_assistant_message(
                message.get("content"),
                effective_shadow_turns,
            )
            .filter(|index| !used_shadow_indices.contains(index));
            assistant_seen_index += 1;
            let shadow_index = tool_use_match_index.or(positional_shadow_index);

            if let Some(index) = shadow_index {
                used_shadow_indices.insert(index);
                let shadow_turn = &effective_shadow_turns[index];
                merge_tool_names_from_shadow(shadow_turn, &mut tool_name_by_id);
                merge_thought_signatures_from_shadow(shadow_turn, &mut thought_signature_by_id);
                if let Some(parts) = shadow_parts(&shadow_turn.assistant_content) {
                    parts
                } else {
                    convert_message_content_to_parts(
                        message.get("content"),
                        role,
                        &mut tool_name_by_id,
                        &thought_signature_by_id,
                    )?
                }
            } else {
                convert_message_content_to_parts(
                    message.get("content"),
                    role,
                    &mut tool_name_by_id,
                    &thought_signature_by_id,
                )?
            }
        } else {
            convert_message_content_to_parts(
                message.get("content"),
                role,
                &mut tool_name_by_id,
                &thought_signature_by_id,
            )?
        };

        if role == "assistant" {
            merge_tool_names_from_parts(&parts, &mut tool_name_by_id);
        }

        contents.push(json!({
            "role": gemini_role,
            "parts": parts
        }));
    }

    Ok(contents)
}

fn find_matching_shadow_turn_for_assistant_message(
    content: Option<&Value>,
    shadow_turns: &[GeminiAssistantTurn],
) -> Option<usize> {
    let (tool_use_ids, tool_use_names) = extract_assistant_tool_use_keys(content);
    if tool_use_ids.is_empty() && tool_use_names.is_empty() {
        return None;
    }

    // Prefer exact tool-call id match. With identical tool suffixes across
    // servers (e.g. `server_a:search` and `server_b:search`) the
    // normalized-name clause below would otherwise match an earlier shadow
    // turn whose id is actually wrong for this message, mis-routing replay
    // state (functionCall id / thoughtSignature) for later tool_result
    // resolution. Only fall back to name matching when id-based lookup fails
    // or when the incoming message carries no ids at all.
    if !tool_use_ids.is_empty() {
        if let Some(index) = shadow_turns.iter().position(|turn| {
            turn.tool_calls.iter().any(|tool_call| {
                tool_call
                    .id
                    .as_deref()
                    .is_some_and(|id| tool_use_ids.contains(id))
            })
        }) {
            return Some(index);
        }
    }

    shadow_turns.iter().enumerate().find_map(|(index, turn)| {
        turn.tool_calls
            .iter()
            .any(|tool_call| {
                tool_use_names.contains(tool_call.name.as_str())
                    || tool_use_names.contains(normalize_tool_name(&tool_call.name))
            })
            .then_some(index)
    })
}

fn extract_assistant_tool_use_keys(content: Option<&Value>) -> (HashSet<String>, HashSet<String>) {
    let mut tool_use_ids = HashSet::new();
    let mut tool_use_names = HashSet::new();
    let Some(blocks) = content.and_then(|value| value.as_array()) else {
        return (tool_use_ids, tool_use_names);
    };

    for block in blocks {
        if block.get("type").and_then(|value| value.as_str()) != Some("tool_use") {
            continue;
        }

        if let Some(id) = block
            .get("id")
            .and_then(|value| value.as_str())
            .filter(|id| !id.is_empty())
        {
            tool_use_ids.insert(id.to_string());
        }

        if let Some(name) = block
            .get("name")
            .and_then(|value| value.as_str())
            .filter(|name| !name.is_empty())
        {
            tool_use_names.insert(name.to_string());
            tool_use_names.insert(normalize_tool_name(name).to_string());
        }
    }

    (tool_use_ids, tool_use_names)
}

fn normalize_tool_name(name: &str) -> &str {
    name.rsplit(':').next().unwrap_or(name)
}

fn convert_message_content_to_parts(
    content: Option<&Value>,
    role: &str,
    tool_name_by_id: &mut std::collections::HashMap<String, String>,
    thought_signature_by_id: &std::collections::HashMap<String, String>,
) -> Result<Vec<Value>, ProxyError> {
    let Some(content) = content else {
        return Ok(Vec::new());
    };

    if let Some(text) = content.as_str() {
        return Ok(vec![json!({ "text": text })]);
    }

    let Some(blocks) = content.as_array() else {
        return Err(ProxyError::TransformError(
            "Anthropic message content must be a string or array".to_string(),
        ));
    };

    let mut parts = Vec::new();

    for block in blocks {
        let block_type = block
            .get("type")
            .and_then(|value| value.as_str())
            .unwrap_or("");

        match block_type {
            "text" => {
                if let Some(text) = block.get("text").and_then(|value| value.as_str()) {
                    parts.push(json!({ "text": text }));
                }
            }
            "image" => {
                let source = block.get("source").ok_or_else(|| {
                    ProxyError::TransformError("Gemini image block missing source".to_string())
                })?;

                let source_type = source
                    .get("type")
                    .and_then(|value| value.as_str())
                    .unwrap_or("");

                if source_type != "base64" {
                    return Err(ProxyError::TransformError(format!(
                        "Gemini Native only supports base64 image sources, got `{source_type}`"
                    )));
                }

                parts.push(json!({
                    "inlineData": {
                        "mimeType": source.get("media_type").and_then(|value| value.as_str()).unwrap_or("image/png"),
                        "data": source.get("data").and_then(|value| value.as_str()).unwrap_or("")
                    }
                }));
            }
            "document" => {
                let source = block.get("source").ok_or_else(|| {
                    ProxyError::TransformError("Gemini document block missing source".to_string())
                })?;

                let source_type = source
                    .get("type")
                    .and_then(|value| value.as_str())
                    .unwrap_or("");

                if source_type != "base64" {
                    return Err(ProxyError::TransformError(format!(
                        "Gemini Native only supports base64 document sources, got `{source_type}`"
                    )));
                }

                parts.push(json!({
                    "inlineData": {
                        "mimeType": source.get("media_type").and_then(|value| value.as_str()).unwrap_or("application/pdf"),
                        "data": source.get("data").and_then(|value| value.as_str()).unwrap_or("")
                    }
                }));
            }
            "tool_use" => {
                if role != "assistant" {
                    return Err(ProxyError::TransformError(
                        "tool_use blocks are only valid in assistant messages".to_string(),
                    ));
                }

                let id = block
                    .get("id")
                    .and_then(|value| value.as_str())
                    .unwrap_or("");
                let name = block
                    .get("name")
                    .and_then(|value| value.as_str())
                    .unwrap_or("");
                if !id.is_empty() && !name.is_empty() {
                    tool_name_by_id.insert(id.to_string(), name.to_string());
                }

                // A synthesized id is an internal proxy identifier — never
                // forward it to Gemini. Gemini will disambiguate the missing
                // id by call order, matching its own earlier response shape.
                let mut function_call = json!({
                    "name": name,
                    "args": block.get("input").cloned().unwrap_or_else(|| json!({}))
                });
                if !id.is_empty() && !is_synthesized_tool_call_id(id) {
                    function_call["id"] = json!(id);
                }

                // Re-attach the thought_signature that Gemini originally
                // associated with this functionCall.  The Anthropic format
                // strips it from the tool_use block, but Gemini requires it
                // on every functionCall in a multi-turn tool-use exchange.
                // Without replaying the stored signature the upstream may
                // reject with "missing a `thought_signature`".
                if let Some(sig) = thought_signature_by_id.get(id) {
                    function_call["thoughtSignature"] = json!(sig);
                }

                parts.push(json!({ "functionCall": function_call }));
            }
            "tool_result" => {
                let tool_use_id = block
                    .get("tool_use_id")
                    .and_then(|value| value.as_str())
                    .unwrap_or("");
                let name = tool_name_by_id
                    .get(tool_use_id)
                    .cloned()
                    .or_else(|| {
                        // Last-resort fallback: scan every block in this content
                        // array for a tool_use whose id matches.  This catches
                        // edge cases where the tool_use lives in a different
                        // content block of the same message (non-standard client
                        // behaviour) or in a re-ordered message array.
                        blocks.iter().find_map(|b| {
                            let t = b.get("type").and_then(|v| v.as_str())?;
                            if t != "tool_use" { return None; }
                            let id = b.get("id").and_then(|v| v.as_str())?;
                            if id != tool_use_id { return None; }
                            b.get("name").and_then(|v| v.as_str()).map(|n| n.to_string())
                        })
                    })
                    .ok_or_else(|| {
                        ProxyError::TransformError(format!(
                            "Unable to resolve Gemini functionResponse.name for tool_use_id `{tool_use_id}`"
                        ))
                    })?;

                // See `tool_use` above: synthesized ids must not leak upstream.
                let mut function_response = json!({
                    "name": name,
                    "response": normalize_tool_result_response(block.get("content"))
                });
                if !tool_use_id.is_empty() && !is_synthesized_tool_call_id(tool_use_id) {
                    function_response["id"] = json!(tool_use_id);
                }

                parts.push(json!({ "functionResponse": function_response }));
            }
            "thinking" | "redacted_thinking" => {}
            _ => {}
        }
    }

    Ok(parts)
}

fn normalize_tool_result_response(content: Option<&Value>) -> Value {
    match content {
        Some(Value::String(text)) => json!({ "content": text }),
        Some(Value::Array(blocks)) => {
            let texts: Vec<&str> = blocks
                .iter()
                .filter(|block| block.get("type").and_then(|value| value.as_str()) == Some("text"))
                .filter_map(|block| block.get("text").and_then(|value| value.as_str()))
                .collect();

            if texts.is_empty() {
                json!({ "content": Value::Array(blocks.clone()) })
            } else {
                json!({ "content": texts.join("\n") })
            }
        }
        Some(value) => json!({ "content": value.clone() }),
        None => json!({ "content": "" }),
    }
}

fn shadow_parts(content: &Value) -> Option<Vec<Value>> {
    let mut parts = content
        .get("parts")
        .and_then(|value| value.as_array())
        .cloned()
        .or_else(|| content.as_array().cloned())?;
    // Strip synthesized ids before these parts are replayed into a Gemini
    // request body. The shadow store records the Anthropic-facing id so that
    // a tool_result round-trip can find the tool's name, but sending the
    // synthetic value as `functionCall.id` upstream would leak an internal
    // identifier.
    for part in &mut parts {
        let Some(function_call) = part.get_mut("functionCall").and_then(|v| v.as_object_mut())
        else {
            continue;
        };
        let drop_id = function_call
            .get("id")
            .and_then(|v| v.as_str())
            .map(|id| id.is_empty() || is_synthesized_tool_call_id(id))
            .unwrap_or(true);
        if drop_id {
            function_call.remove("id");
        }
    }
    Some(parts)
}

pub fn extract_anthropic_tool_schema_hints(body: &Value) -> AnthropicToolSchemaHints {
    body.get("tools")
        .and_then(|value| value.as_array())
        .into_iter()
        .flatten()
        .filter_map(|tool| {
            let name = tool.get("name").and_then(|value| value.as_str())?;
            let input_schema = tool
                .get("input_schema")
                .and_then(|value| value.as_object())?;
            let properties = input_schema
                .get("properties")
                .and_then(|value| value.as_object())?;
            if properties.is_empty() {
                return None;
            }

            let expected_keys = properties.keys().cloned().collect::<Vec<_>>();
            let required_keys = input_schema
                .get("required")
                .and_then(|value| value.as_array())
                .map(|values| {
                    values
                        .iter()
                        .filter_map(|value| value.as_str().map(ToString::to_string))
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default();

            Some((
                name.to_string(),
                AnthropicToolSchemaHint {
                    expected_keys,
                    required_keys,
                },
            ))
        })
        .collect()
}

pub fn rectify_tool_call_parts(
    parts: &mut [Value],
    tool_schema_hints: Option<&AnthropicToolSchemaHints>,
) {
    for part in parts {
        let Some(function_call) = part
            .get_mut("functionCall")
            .and_then(|value| value.as_object_mut())
        else {
            continue;
        };
        let Some(name) = function_call
            .get("name")
            .and_then(|value| value.as_str())
            .map(ToString::to_string)
        else {
            continue;
        };
        let Some(args) = function_call.get_mut("args") else {
            continue;
        };

        if rectify_tool_call_args(&name, args, tool_schema_hints) {
            log::info!("[Claude/Gemini] Rectified tool args for `{name}`");
        }
    }
}

pub fn rectify_tool_call_args(
    tool_name: &str,
    args: &mut Value,
    tool_schema_hints: Option<&AnthropicToolSchemaHints>,
) -> bool {
    let Some(tool_schema_hints) = tool_schema_hints else {
        return false;
    };
    let Some(hint) = tool_schema_hints.get(tool_name) else {
        return false;
    };
    let Some(args_object) = args.as_object_mut() else {
        return false;
    };
    if args_object.is_empty() || hint.expected_keys.is_empty() {
        return false;
    }
    let mut changed = false;

    if hint.expected_keys.iter().any(|key| key == "skill") && !args_object.contains_key("skill") {
        if let Some(value) = args_object.remove("name") {
            args_object.insert("skill".to_string(), value);
            changed = true;
        }
    }

    let expects_parameters_key = hint.expected_keys.iter().any(|key| key == "parameters");
    if !expects_parameters_key {
        let extracted_parameters = args_object
            .get("parameters")
            .and_then(|value| value.as_object())
            .map(|parameters_object| {
                hint.expected_keys
                    .iter()
                    .filter_map(|expected_key| {
                        if args_object.contains_key(expected_key) {
                            return None;
                        }
                        let value = parameters_object.get(expected_key)?;
                        let normalized_value = match value {
                            Value::Array(values) if values.len() == 1 => values[0].clone(),
                            _ => value.clone(),
                        };
                        Some((expected_key.clone(), normalized_value))
                    })
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default();

        if !extracted_parameters.is_empty() {
            for (expected_key, normalized_value) in extracted_parameters {
                args_object.insert(expected_key, normalized_value);
            }
            args_object.remove("parameters");
            changed = true;
        }
    }

    if hint
        .required_keys
        .iter()
        .all(|key| args_object.contains_key(key.as_str()))
    {
        return changed;
    }

    let expected_key_set = hint
        .expected_keys
        .iter()
        .map(String::as_str)
        .collect::<HashSet<_>>();
    let unexpected_keys = args_object
        .keys()
        .filter(|key| !expected_key_set.contains(key.as_str()))
        .cloned()
        .collect::<Vec<_>>();
    if unexpected_keys.len() != 1 {
        return false;
    }

    let target_key = hint
        .required_keys
        .iter()
        .find(|key| !args_object.contains_key(key.as_str()))
        .cloned()
        .or_else(|| {
            if hint.expected_keys.len() == 1 && args_object.len() == 1 {
                hint.expected_keys.first().cloned()
            } else {
                None
            }
        });
    let Some(target_key) = target_key else {
        return false;
    };
    if args_object.contains_key(&target_key) {
        return false;
    }

    let source_key = &unexpected_keys[0];
    let Some(value) = args_object.remove(source_key) else {
        return false;
    };
    args_object.insert(target_key, value);
    true
}

fn merge_tool_names_from_shadow(
    turn: &GeminiAssistantTurn,
    tool_name_by_id: &mut HashMap<String, String>,
) {
    for tool_call in &turn.tool_calls {
        if let Some(id) = &tool_call.id {
            tool_name_by_id.insert(id.clone(), tool_call.name.clone());
        }
    }

    if let Some(parts) = shadow_parts(&turn.assistant_content) {
        merge_tool_names_from_parts(&parts, tool_name_by_id);
    }
}

fn build_tool_name_map_from_shadow_turns(
    shadow_turns: &[GeminiAssistantTurn],
) -> HashMap<String, String> {
    let mut tool_name_by_id = HashMap::new();
    for turn in shadow_turns {
        merge_tool_names_from_shadow(turn, &mut tool_name_by_id);
    }
    tool_name_by_id
}

fn build_thought_signature_map_from_shadow_turns(
    shadow_turns: &[GeminiAssistantTurn],
) -> HashMap<String, String> {
    let mut thought_signature_by_id = HashMap::new();
    for turn in shadow_turns {
        merge_thought_signatures_from_shadow(turn, &mut thought_signature_by_id);
    }
    thought_signature_by_id
}

fn merge_thought_signatures_from_shadow(
    turn: &GeminiAssistantTurn,
    thought_signature_by_id: &mut HashMap<String, String>,
) {
    for tool_call in &turn.tool_calls {
        if let (Some(id), Some(sig)) = (&tool_call.id, &tool_call.thought_signature) {
            thought_signature_by_id.insert(id.clone(), sig.clone());
        }
    }
}

fn merge_tool_names_from_parts(parts: &[Value], tool_name_by_id: &mut HashMap<String, String>) {
    for part in parts {
        let Some(function_call) = part.get("functionCall") else {
            continue;
        };
        let Some(id) = function_call.get("id").and_then(|value| value.as_str()) else {
            continue;
        };
        let Some(name) = function_call.get("name").and_then(|value| value.as_str()) else {
            continue;
        };
        if !id.is_empty() && !name.is_empty() {
            tool_name_by_id.insert(id.to_string(), name.to_string());
        }
    }
}

fn extract_tool_call_meta(parts: &[Value]) -> Vec<GeminiToolCallMeta> {
    parts
        .iter()
        .filter_map(|part| {
            let function_call = part.get("functionCall")?;
            // Ensure every surfaced tool call carries a distinguishing id.
            // Gemini 2.x may omit `id` on parallel calls; synthesizing a
            // unique replacement prevents downstream merge/replay logic from
            // collapsing distinct calls onto a single empty-string key.
            let id = function_call
                .get("id")
                .and_then(|value| value.as_str())
                .filter(|s| !s.is_empty())
                .map(ToString::to_string)
                .unwrap_or_else(synthesize_tool_call_id);
            Some(GeminiToolCallMeta::new(
                Some(id),
                function_call
                    .get("name")
                    .and_then(|value| value.as_str())
                    .unwrap_or(""),
                function_call
                    .get("args")
                    .cloned()
                    .unwrap_or_else(|| json!({})),
                part.get("thoughtSignature")
                    .or_else(|| part.get("thought_signature"))
                    .and_then(|value| value.as_str()),
            ))
        })
        .collect()
}

fn map_tool_choice(tool_choice: Option<&Value>) -> Result<Option<Value>, ProxyError> {
    let Some(tool_choice) = tool_choice else {
        return Ok(None);
    };

    match tool_choice {
        Value::String(choice) => Ok(match choice.as_str() {
            "auto" => Some(json!({
                "functionCallingConfig": { "mode": "AUTO" }
            })),
            "none" => Some(json!({
                "functionCallingConfig": { "mode": "NONE" }
            })),
            other => {
                return Err(ProxyError::TransformError(format!(
                    "Unsupported Gemini tool_choice string: {other}"
                )));
            }
        }),
        Value::Object(object) => {
            let Some(choice_type) = object.get("type").and_then(|value| value.as_str()) else {
                return Ok(None);
            };

            let config = match choice_type {
                "auto" => json!({ "mode": "AUTO" }),
                "none" => json!({ "mode": "NONE" }),
                "any" => json!({ "mode": "ANY" }),
                "tool" => {
                    let name = object
                        .get("name")
                        .and_then(|value| value.as_str())
                        .unwrap_or("");
                    json!({
                        "mode": "ANY",
                        "allowedFunctionNames": [name]
                    })
                }
                other => {
                    return Err(ProxyError::TransformError(format!(
                        "Unsupported Gemini tool_choice type: {other}"
                    )));
                }
            };

            Ok(Some(json!({ "functionCallingConfig": config })))
        }
        _ => Ok(None),
    }
}

/// Convert a Gemini `usageMetadata` object into an Anthropic-style `usage`
/// object. Used by both the streaming SSE converter and the non-streaming
/// transform path so the two emit identical shapes.
pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
    let Some(usage) = usage else {
        return json!({
            "input_tokens": 0,
            "output_tokens": 0
        });
    };

    let prompt_tokens = usage
        .get("promptTokenCount")
        .and_then(|value| value.as_u64())
        .unwrap_or(0);
    let total_tokens = usage
        .get("totalTokenCount")
        .and_then(|value| value.as_u64())
        .unwrap_or(0);
    let cached_tokens = usage
        .get("cachedContentTokenCount")
        .and_then(|value| value.as_u64())
        .unwrap_or(0);
    // Gemini 的 promptTokenCount 含缓存命中（cachedContentTokenCount）；而 Anthropic
    // 语义下 input_tokens 必须是不含 cache 的 fresh input、cache_read 单列。本路径转成
    // Anthropic 后以 app_type=claude 记账，calculator 对 claude 设 input_includes_cache_read
    // =false 不再从 input 扣 cache，因此这里必须先扣减，否则缓存 token 会被双重计费
    // （一次按完整 input 价、一次按 cache_read 价）。output 仍按 total-prompt 计算
    // （prompt 是总输入，扣减只作用于 input/cache 的拆分，不影响 output）。
    let input_tokens = prompt_tokens.saturating_sub(cached_tokens);
    let output_tokens = total_tokens.saturating_sub(prompt_tokens);

    let mut result = json!({
        "input_tokens": input_tokens,
        "output_tokens": output_tokens
    });

    if cached_tokens > 0 {
        result["cache_read_input_tokens"] = json!(cached_tokens);
    }

    result
}

fn map_finish_reason(reason: Option<&str>, has_tool_use: bool) -> Value {
    let mapped = match reason {
        Some("MAX_TOKENS") => Some("max_tokens"),
        Some("STOP") | Some("FINISH_REASON_UNSPECIFIED") | None => {
            if has_tool_use {
                Some("tool_use")
            } else {
                Some("end_turn")
            }
        }
        Some("SAFETY")
        | Some("RECITATION")
        | Some("SPII")
        | Some("BLOCKLIST")
        | Some("PROHIBITED_CONTENT") => Some("refusal"),
        Some(other) => {
            log::warn!("[Claude/Gemini] Unknown Gemini finishReason `{other}`, using end_turn");
            Some("end_turn")
        }
    };

    match mapped {
        Some(value) => json!(value),
        None => Value::Null,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn anthropic_to_gemini_maps_system_and_messages() {
        let input = json!({
            "model": "gemini-2.5-pro",
            "max_tokens": 128,
            "system": "You are helpful.",
            "messages": [
                { "role": "user", "content": "Hello" }
            ]
        });

        let result = anthropic_to_gemini(input).unwrap();
        assert_eq!(
            result["systemInstruction"]["parts"][0]["text"],
            "You are helpful."
        );
        assert_eq!(result["contents"][0]["role"], "user");
        assert_eq!(result["contents"][0]["parts"][0]["text"], "Hello");
        assert_eq!(result["generationConfig"]["maxOutputTokens"], 128);
    }

    #[test]
    fn anthropic_to_gemini_merges_system_messages_into_system_instruction() {
        let input = json!({
            "model": "gemini-3-pro",
            "system": [{ "type": "text", "text": "Top level system." }],
            "messages": [
                { "role": "system", "content": "Message system." },
                {
                    "role": "system",
                    "content": [{ "type": "text", "text": "Block system." }]
                },
                { "role": "user", "content": "Hello" }
            ]
        });

        let result = anthropic_to_gemini(input).unwrap();

        assert_eq!(
            result["systemInstruction"]["parts"][0]["text"],
            "Top level system.\n\nMessage system.\n\nBlock system."
        );
        assert_eq!(result["contents"].as_array().unwrap().len(), 1);
        assert_eq!(result["contents"][0]["role"], "user");
        assert_eq!(result["contents"][0]["parts"][0]["text"], "Hello");
    }

    #[test]
    fn anthropic_to_gemini_maps_tools_and_tool_results() {
        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Tokyo" } }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" }
                    ]
                }
            ],
            "tools": [
                {
                    "name": "get_weather",
                    "description": "Weather lookup",
                    "input_schema": { "type": "object", "properties": { "city": { "type": "string" } } }
                }
            ],
            "tool_choice": { "type": "tool", "name": "get_weather" }
        });

        let result = anthropic_to_gemini(input).unwrap();
        assert_eq!(
            result["tools"][0]["functionDeclarations"][0]["name"],
            "get_weather"
        );
        assert!(result["tools"][0]["functionDeclarations"][0]
            .get("parameters")
            .is_some());
        assert_eq!(
            result["contents"][0]["parts"][0]["functionCall"]["name"],
            "get_weather"
        );
        assert_eq!(
            result["contents"][1]["parts"][0]["functionResponse"]["name"],
            "get_weather"
        );
        assert_eq!(
            result["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"][0],
            "get_weather"
        );
    }

    #[test]
    fn anthropic_to_gemini_resolves_tool_result_name_from_shadow_content() {
        let store = GeminiShadowStore::with_limits(8, 4);
        store.record_assistant_turn(
            "provider-a",
            "session-1",
            json!({
                "parts": [{
                    "functionCall": {
                        "id": "call_1",
                        "name": "get_weather",
                        "args": { "city": "Tokyo" }
                    }
                }]
            }),
            vec![],
        );

        let input = json!({
            "messages": [
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" }
                    ]
                }
            ]
        });

        let result = anthropic_to_gemini_with_shadow(
            input,
            Some(&store),
            Some("provider-a"),
            Some("session-1"),
        )
        .unwrap();

        assert_eq!(
            result["contents"][0]["parts"][0]["functionResponse"]["name"],
            "get_weather"
        );
    }

    #[test]
    fn anthropic_to_gemini_rejects_tool_result_without_resolvable_name() {
        let input = json!({
            "messages": [
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" }
                    ]
                }
            ]
        });

        let error = anthropic_to_gemini(input).unwrap_err();
        assert!(error
            .to_string()
            .contains("Unable to resolve Gemini functionResponse.name"));
    }

    #[test]
    fn anthropic_to_gemini_uses_parameters_json_schema_for_rich_tool_schema() {
        let input = json!({
            "tools": [
                {
                    "name": "search",
                    "description": "Search data",
                    "input_schema": {
                        "$schema": "https://json-schema.org/draft/2020-12/schema",
                        "type": "object",
                        "properties": {
                            "query": { "type": "string" }
                        },
                        "required": ["query"],
                        "additionalProperties": false
                    }
                }
            ]
        });

        let result = anthropic_to_gemini(input).unwrap();
        let declaration = &result["tools"][0]["functionDeclarations"][0];

        assert!(declaration.get("parameters").is_none());
        assert!(declaration.get("parametersJsonSchema").is_some());
        assert!(declaration["parametersJsonSchema"].get("$schema").is_none());
        assert_eq!(
            declaration["parametersJsonSchema"]["additionalProperties"],
            false
        );
    }

    #[test]
    fn gemini_to_anthropic_maps_text_and_usage() {
        let input = json!({
            "responseId": "resp_1",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [{ "text": "Hello from Gemini" }]
                }
            }],
            "usageMetadata": {
                "promptTokenCount": 12,
                "totalTokenCount": 20,
                "cachedContentTokenCount": 3
            }
        });

        let result = gemini_to_anthropic(input).unwrap();
        assert_eq!(result["id"], "resp_1");
        assert_eq!(result["content"][0]["type"], "text");
        assert_eq!(result["content"][0]["text"], "Hello from Gemini");
        assert_eq!(result["stop_reason"], "end_turn");
        // input_tokens = promptTokenCount(12) - cachedContentTokenCount(3) = 9（fresh input）。
        // Gemini 的 promptTokenCount 含缓存命中，但 Anthropic 语义要求 input 不含 cache、
        // cache_read 单列；二者相加(9+3)=总输入 12。扣减避免本路径以 app_type=claude
        // 记账时把缓存 token 双重计费。
        assert_eq!(result["usage"]["input_tokens"], 9);
        assert_eq!(result["usage"]["output_tokens"], 8);
        assert_eq!(result["usage"]["cache_read_input_tokens"], 3);
    }

    #[test]
    fn gemini_to_anthropic_maps_function_calls_to_tool_use() {
        let input = json!({
            "responseId": "resp_2",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [{
                        "functionCall": {
                            "id": "call_1",
                            "name": "get_weather",
                            "args": { "city": "Tokyo" }
                        }
                    }]
                }
            }],
            "usageMetadata": {
                "promptTokenCount": 10,
                "totalTokenCount": 15
            }
        });

        let result = gemini_to_anthropic(input).unwrap();
        assert_eq!(result["content"][0]["type"], "tool_use");
        assert_eq!(result["content"][0]["id"], "call_1");
        assert_eq!(result["stop_reason"], "tool_use");
    }

    #[test]
    fn gemini_to_anthropic_rectifies_tool_args_from_schema_hints() {
        let input = json!({
            "responseId": "resp_2",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [{
                        "functionCall": {
                            "id": "call_1",
                            "name": "Skill",
                            "args": {
                                "name": "git-commit",
                                "parameters": {
                                    "args": ["详细分析内容 编写提交信息 分多次提交代码"]
                                }
                            }
                        }
                    }]
                }
            }]
        });
        let hints = extract_anthropic_tool_schema_hints(&json!({
            "tools": [{
                "name": "Skill",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "skill": { "type": "string" },
                        "args": { "type": "string" }
                    },
                    "required": ["skill"]
                }
            }]
        }));

        let result =
            gemini_to_anthropic_with_shadow_and_hints(input, None, None, None, Some(&hints))
                .unwrap();

        assert_eq!(result["content"][0]["input"]["skill"], "git-commit");
        assert_eq!(
            result["content"][0]["input"]["args"],
            "详细分析内容 编写提交信息 分多次提交代码"
        );
        assert!(result["content"][0]["input"].get("name").is_none());
        assert!(result["content"][0]["input"].get("parameters").is_none());
    }

    #[test]
    fn gemini_to_anthropic_preserves_legitimate_parameters_arg() {
        let input = json!({
            "responseId": "resp_params",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [{
                        "functionCall": {
                            "id": "call_1",
                            "name": "ConfigTool",
                            "args": {
                                "parameters": {
                                    "mode": "safe",
                                    "retries": 2
                                }
                            }
                        }
                    }]
                }
            }]
        });
        let hints = extract_anthropic_tool_schema_hints(&json!({
            "tools": [{
                "name": "ConfigTool",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "parameters": {
                            "type": "object",
                            "properties": {
                                "mode": { "type": "string" },
                                "retries": { "type": "integer" }
                            }
                        }
                    },
                    "required": ["parameters"]
                }
            }]
        }));

        let result =
            gemini_to_anthropic_with_shadow_and_hints(input, None, None, None, Some(&hints))
                .unwrap();

        assert_eq!(result["content"][0]["input"]["parameters"]["mode"], "safe");
        assert_eq!(result["content"][0]["input"]["parameters"]["retries"], 2);
    }

    #[test]
    fn gemini_to_anthropic_maps_blocked_prompt_to_refusal() {
        let input = json!({
            "responseId": "resp_3",
            "modelVersion": "gemini-2.5-flash",
            "promptFeedback": { "blockReason": "SAFETY" },
            "usageMetadata": {
                "promptTokenCount": 4,
                "totalTokenCount": 4
            }
        });

        let result = gemini_to_anthropic(input).unwrap();
        assert_eq!(result["stop_reason"], "refusal");
        assert_eq!(result["content"][0]["type"], "text");
        assert!(result["content"][0]["text"]
            .as_str()
            .unwrap()
            .contains("SAFETY"));
    }

    #[test]
    fn shadow_replay_aligns_to_latest_turns_after_client_truncation() {
        let store = GeminiShadowStore::with_limits(8, 4);
        // Record 3 shadow turns (assistant messages 0, 1, 2)
        for i in 0..3 {
            store.record_assistant_turn(
                "prov",
                "sess",
                json!({
                    "parts": [{
                        "functionCall": {
                            "id": format!("call_{i}"),
                            "name": format!("tool_{i}"),
                            "args": {}
                        }
                    }]
                }),
                vec![],
            );
        }

        // Client truncates history: only sends assistant messages 1 and 2
        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": "call_1", "name": "tool_1", "input": {} }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_1", "content": "ok" }
                    ]
                },
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": "call_2", "name": "tool_2", "input": {} }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_2", "content": "ok" }
                    ]
                }
            ]
        });

        let result =
            anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
                .unwrap();

        // Shadow turns[1] (tool_1) should align with first assistant message,
        // shadow turns[2] (tool_2) with the second — not turns[0] and turns[1].
        assert_eq!(
            result["contents"][0]["parts"][0]["functionCall"]["name"],
            "tool_1"
        );
        assert_eq!(
            result["contents"][2]["parts"][0]["functionCall"]["name"],
            "tool_2"
        );
    }

    #[test]
    fn shadow_replay_matches_tool_use_turn_by_id_when_position_drifts() {
        let store = GeminiShadowStore::with_limits(8, 4);
        store.record_assistant_turn(
            "prov",
            "sess",
            json!({
                "parts": [{
                    "functionCall": {
                        "id": "call_1",
                        "name": "Bash",
                        "args": { "command": "ls -R" }
                    },
                    "thoughtSignature": "sig-tool-1"
                }]
            }),
            vec![GeminiToolCallMeta::new(
                Some("call_1"),
                "Bash",
                json!({ "command": "ls -R" }),
                Some("sig-tool-1"),
            )],
        );

        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        {
                            "type": "tool_use",
                            "id": "call_1",
                            "name": "default_api:Bash",
                            "input": { "command": "ls -R" }
                        }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_1", "content": "ok" }
                    ]
                },
                {
                    "role": "assistant",
                    "content": [
                        { "type": "text", "text": "local-only assistant turn without Gemini shadow" }
                    ]
                }
            ]
        });

        let result =
            anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
                .unwrap();

        assert_eq!(
            result["contents"][0]["parts"][0]["functionCall"]["name"],
            "Bash"
        );
        assert_eq!(
            result["contents"][0]["parts"][0]["thoughtSignature"],
            "sig-tool-1"
        );
    }

    /// Regression for P1: two shadow turns whose suffix-normalized names
    /// collide (e.g. `server_a:search` / `server_b:search` both normalize to
    /// `search`). When the incoming assistant tool_use carries a valid,
    /// different id, exact-id matching must win over the normalized-name
    /// clause — otherwise replay picks the wrong shadow turn and later
    /// tool_result resolution mis-routes.
    #[test]
    fn shadow_replay_prefers_exact_id_match_over_normalized_name_collision() {
        let store = GeminiShadowStore::with_limits(8, 4);
        store.record_assistant_turn(
            "prov",
            "sess",
            json!({
                "parts": [{
                    "functionCall": {
                        "id": "call_a",
                        "name": "server_a:search",
                        "args": { "q": "alpha" }
                    },
                    "thoughtSignature": "sig-a"
                }]
            }),
            vec![GeminiToolCallMeta::new(
                Some("call_a"),
                "server_a:search",
                json!({ "q": "alpha" }),
                Some("sig-a"),
            )],
        );
        store.record_assistant_turn(
            "prov",
            "sess",
            json!({
                "parts": [{
                    "functionCall": {
                        "id": "call_b",
                        "name": "server_b:search",
                        "args": { "q": "beta" }
                    },
                    "thoughtSignature": "sig-b"
                }]
            }),
            vec![GeminiToolCallMeta::new(
                Some("call_b"),
                "server_b:search",
                json!({ "q": "beta" }),
                Some("sig-b"),
            )],
        );

        // Two assistant turns: the first references call_b, the second
        // call_a. Positional fallback would align msg[0] to turn 0 (call_a)
        // and msg[1] to turn 1 (call_b) — both wrong. The old `||` chain
        // would also mis-match through the normalized "search" name.
        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": "call_b", "name": "server_b:search", "input": { "q": "beta" } }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_b", "content": "ok-b" }
                    ]
                },
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": "call_a", "name": "server_a:search", "input": { "q": "alpha" } }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_a", "content": "ok-a" }
                    ]
                }
            ]
        });

        let result =
            anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
                .unwrap();

        // msg[0] replays shadow turn 1 (server_b:search) because id=call_b.
        assert_eq!(
            result["contents"][0]["parts"][0]["functionCall"]["name"],
            "server_b:search"
        );
        assert_eq!(
            result["contents"][0]["parts"][0]["thoughtSignature"],
            "sig-b"
        );
        // msg[2] replays shadow turn 0 (server_a:search) because id=call_a,
        // even though turn 1 was already consumed above.
        assert_eq!(
            result["contents"][2]["parts"][0]["functionCall"]["name"],
            "server_a:search"
        );
        assert_eq!(
            result["contents"][2]["parts"][0]["thoughtSignature"],
            "sig-a"
        );
    }

    /// When the incoming tool_use carries no id (or only empty-string ids),
    /// the layered matcher must still fall back to name-based matching so
    /// that shadow replay keeps working for providers that omit ids.
    #[test]
    fn shadow_replay_falls_back_to_name_when_ids_absent() {
        let store = GeminiShadowStore::with_limits(8, 4);
        store.record_assistant_turn(
            "prov",
            "sess",
            json!({
                "parts": [{
                    "functionCall": {
                        "name": "lookup",
                        "args": {}
                    },
                    "thoughtSignature": "sig-lookup"
                }]
            }),
            vec![GeminiToolCallMeta::new(
                None::<&str>,
                "lookup",
                json!({}),
                Some("sig-lookup"),
            )],
        );

        // id is an empty string; extract_assistant_tool_use_keys filters it
        // out, so tool_use_ids is empty and matching must go through names.
        // A trailing user text turn keeps the assistant turn well-formed
        // without feeding a tool_result back (which would require a real id).
        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": "", "name": "lookup", "input": {} }
                    ]
                },
                {
                    "role": "user",
                    "content": "ack"
                }
            ]
        });

        let result =
            anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
                .unwrap();

        assert_eq!(
            result["contents"][0]["parts"][0]["functionCall"]["name"],
            "lookup"
        );
        assert_eq!(
            result["contents"][0]["parts"][0]["thoughtSignature"],
            "sig-lookup"
        );
    }

    /// Regression for P1: Gemini 2.x may return parallel calls without ids.
    /// Each Anthropic-visible tool_use must carry a unique id so the Claude
    /// Code client can map tool_result responses back correctly.
    #[test]
    fn gemini_to_anthropic_synthesizes_unique_ids_for_missing_functioncall_ids() {
        let input = json!({
            "responseId": "r1",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [
                        { "functionCall": { "name": "foo", "args": {} } },
                        { "functionCall": { "name": "foo", "args": { "k": 1 } } }
                    ]
                }
            }]
        });

        let result = gemini_to_anthropic(input).unwrap();
        let id0 = result["content"][0]["id"].as_str().unwrap();
        let id1 = result["content"][1]["id"].as_str().unwrap();
        assert!(is_synthesized_tool_call_id(id0));
        assert!(is_synthesized_tool_call_id(id1));
        assert_ne!(id0, id1, "synthesized ids must be unique per call");
    }

    /// Ensures the proxy does not leak synthesized ids back to Gemini when
    /// Claude Code replies with a tool_result: the id must be stripped from
    /// both `functionCall.id` and `functionResponse.id`.
    #[test]
    fn tool_result_with_synthesized_id_omits_id_in_gemini_request() {
        let synth = synthesize_tool_call_id();
        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": &synth, "name": "get_weather", "input": { "city": "X" } }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": &synth, "content": "sunny" }
                    ]
                }
            ]
        });

        let result = anthropic_to_gemini(input).unwrap();
        let fc = &result["contents"][0]["parts"][0]["functionCall"];
        assert!(
            fc.get("id").is_none(),
            "synthesized id must not leak upstream in functionCall"
        );
        assert_eq!(fc["name"], "get_weather");
        let fr = &result["contents"][1]["parts"][0]["functionResponse"];
        assert!(
            fr.get("id").is_none(),
            "synthesized id must not leak upstream in functionResponse"
        );
        assert_eq!(fr["name"], "get_weather");
    }

    /// Genuine Gemini-assigned ids must round-trip unchanged so that Gemini
    /// can correlate the tool result with its own prior functionCall entry.
    #[test]
    fn tool_result_with_genuine_gemini_id_round_trips() {
        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": "call_real_1", "name": "get_weather", "input": {} }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": "call_real_1", "content": "ok" }
                    ]
                }
            ]
        });

        let result = anthropic_to_gemini(input).unwrap();
        assert_eq!(
            result["contents"][0]["parts"][0]["functionCall"]["id"],
            "call_real_1"
        );
        assert_eq!(
            result["contents"][1]["parts"][0]["functionResponse"]["id"],
            "call_real_1"
        );
    }

    /// Shadow replay must also strip synthesized ids when it reconstructs
    /// the assistant's `functionCall` parts from a previously recorded turn.
    #[test]
    fn shadow_replay_strips_synthesized_id_from_function_call() {
        let store = GeminiShadowStore::with_limits(8, 4);
        let synth = synthesize_tool_call_id();
        store.record_assistant_turn(
            "prov",
            "sess",
            json!({
                "parts": [{
                    "functionCall": {
                        "id": &synth,
                        "name": "get_weather",
                        "args": { "city": "Tokyo" }
                    }
                }]
            }),
            vec![GeminiToolCallMeta::new(
                Some(synth.clone()),
                "get_weather",
                json!({ "city": "Tokyo" }),
                None::<String>,
            )],
        );

        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        { "type": "tool_use", "id": &synth, "name": "get_weather", "input": { "city": "Tokyo" } }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": &synth, "content": "sunny" }
                    ]
                }
            ]
        });

        let result =
            anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
                .unwrap();
        // The assistant message was replayed from shadow; its synthesized id
        // must be absent from the upstream functionCall representation.
        assert!(result["contents"][0]["parts"][0]["functionCall"]
            .get("id")
            .is_none());
        // And the tool_result round-trip must still resolve the name via the
        // shadow map even when the id is synthesized.
        assert_eq!(
            result["contents"][1]["parts"][0]["functionResponse"]["name"],
            "get_weather"
        );
    }

    // ------------------------------------------------------------------
    // Non-streaming shadow id coherence regressions.
    //
    // When Gemini returns a `functionCall` without an id (common in 2.x
    // parallel calls) the proxy must synthesize a single id that is
    // consistent across:
    //   (a) the Anthropic `content[tool_use].id` sent to the client
    //   (b) `shadow_content.parts[].functionCall.id` recorded in shadow
    //   (c) `shadow_turn.tool_calls[].id` recorded in shadow
    // Previously the non-streaming path generated independent UUIDs in (a)
    // and (c), so the next round's `tool_result(tool_use_id=A)` would
    // fail to resolve through `tool_name_by_id` (populated from (c)).
    // ------------------------------------------------------------------

    /// The id surfaced to the Anthropic client must equal the id recorded
    /// in the shadow's `tool_calls` metadata and the shadow's serialized
    /// `functionCall.id`. All three are read back as the same string.
    #[test]
    fn non_stream_shadow_id_matches_client_visible_id() {
        let store = GeminiShadowStore::with_limits(8, 4);
        let body = json!({
            "responseId": "r-coherence",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [{
                        "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } }
                    }]
                }
            }]
        });

        let response = gemini_to_anthropic_with_shadow_and_hints(
            body,
            Some(&store),
            Some("prov"),
            Some("sess"),
            None,
        )
        .unwrap();

        let client_id = response["content"][0]["id"].as_str().unwrap();
        assert!(
            is_synthesized_tool_call_id(client_id),
            "client-facing id must be synthesized for no-id Gemini responses"
        );

        let snapshot = store.get_session("prov", "sess").expect("shadow recorded");
        // (c) tool_calls metadata must agree with the client-visible id.
        let shadow_tool_call_id = snapshot.turns[0].tool_calls[0]
            .id
            .as_deref()
            .expect("tool_calls id populated");
        assert_eq!(
            shadow_tool_call_id, client_id,
            "shadow.tool_calls id must equal client-visible id"
        );
        // (b) assistant_content parts must agree too, so that
        // `merge_tool_names_from_parts` sees the same id on replay.
        let shadow_part_id = snapshot.turns[0].assistant_content["parts"][0]["functionCall"]["id"]
            .as_str()
            .expect("assistant_content functionCall id populated");
        assert_eq!(
            shadow_part_id, client_id,
            "shadow assistant_content functionCall.id must equal client-visible id"
        );
    }

    /// Scenario A: the client-side history was truncated so the next
    /// request only contains `[tool_result(tool_use_id=A)]` without a
    /// preceding assistant echo. The request must still resolve because
    /// `build_tool_name_map_from_shadow_turns` now surfaces the same id
    /// the client was given.
    #[test]
    fn non_stream_missing_id_scenario_a_truncated_history_resolves() {
        let store = GeminiShadowStore::with_limits(8, 4);
        let turn1 = json!({
            "responseId": "r-truncated",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [{
                        "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } }
                    }]
                }
            }]
        });
        let anthropic_response = gemini_to_anthropic_with_shadow_and_hints(
            turn1,
            Some(&store),
            Some("prov"),
            Some("sess"),
            None,
        )
        .unwrap();
        let client_id = anthropic_response["content"][0]["id"]
            .as_str()
            .unwrap()
            .to_string();

        // Turn 2 — client replays ONLY the tool_result. No assistant echo.
        let turn2_input = json!({
            "messages": [
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": &client_id, "content": "sunny" }
                    ]
                }
            ]
        });
        let result =
            anthropic_to_gemini_with_shadow(turn2_input, Some(&store), Some("prov"), Some("sess"))
                .expect("scenario A must resolve tool name through shadow");
        assert_eq!(
            result["contents"][0]["parts"][0]["functionResponse"]["name"],
            "get_weather"
        );
    }

    /// Scenario B: the client replays the full history. The proxy picks
    /// the shadow-replay branch (not `convert_message_content_to_parts`),
    /// which strips the synthesized id from the outgoing `functionCall`.
    /// `tool_name_by_id` must still have been populated from the shadow
    /// so the following `tool_result(A)` resolves.
    #[test]
    fn non_stream_missing_id_scenario_b_full_history_replay_resolves() {
        let store = GeminiShadowStore::with_limits(8, 4);
        let turn1 = json!({
            "responseId": "r-full",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [{
                        "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } }
                    }]
                }
            }]
        });
        let anthropic_response = gemini_to_anthropic_with_shadow_and_hints(
            turn1,
            Some(&store),
            Some("prov"),
            Some("sess"),
            None,
        )
        .unwrap();
        let client_id = anthropic_response["content"][0]["id"]
            .as_str()
            .unwrap()
            .to_string();

        // Turn 2 — full history: assistant tool_use + tool_result.
        let turn2_input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        {
                            "type": "tool_use",
                            "id": &client_id,
                            "name": "get_weather",
                            "input": { "city": "Tokyo" }
                        }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": &client_id, "content": "sunny" }
                    ]
                }
            ]
        });
        let result =
            anthropic_to_gemini_with_shadow(turn2_input, Some(&store), Some("prov"), Some("sess"))
                .expect("scenario B must resolve tool name through shadow replay");

        // Shadow-replay path: `functionCall.id` is stripped for the
        // assistant turn (the synthesized id must not leak upstream).
        assert!(
            result["contents"][0]["parts"][0]["functionCall"]
                .get("id")
                .is_none(),
            "synthesized id must not leak to Gemini in shadow replay"
        );
        assert_eq!(
            result["contents"][0]["parts"][0]["functionCall"]["name"],
            "get_weather"
        );
        // The tool_result round-trip resolves through the shadow map.
        assert_eq!(
            result["contents"][1]["parts"][0]["functionResponse"]["name"],
            "get_weather"
        );
    }

    /// Regression: when Gemini returns an id, nothing is synthesized.
    /// The original id is round-tripped in both the Anthropic response
    /// and the shadow store, and it flows back to Gemini on the next
    /// functionResponse.
    #[test]
    fn non_stream_preserves_original_gemini_id_when_present() {
        let store = GeminiShadowStore::with_limits(8, 4);
        let body = json!({
            "responseId": "r-preserve",
            "modelVersion": "gemini-2.5-pro",
            "candidates": [{
                "finishReason": "STOP",
                "content": {
                    "parts": [{
                        "functionCall": {
                            "id": "call_real_1",
                            "name": "get_weather",
                            "args": { "city": "Tokyo" }
                        }
                    }]
                }
            }]
        });

        let response = gemini_to_anthropic_with_shadow_and_hints(
            body,
            Some(&store),
            Some("prov"),
            Some("sess"),
            None,
        )
        .unwrap();
        assert_eq!(response["content"][0]["id"], "call_real_1");
        let snapshot = store.get_session("prov", "sess").unwrap();
        assert_eq!(
            snapshot.turns[0].tool_calls[0].id.as_deref(),
            Some("call_real_1")
        );
        assert_eq!(
            snapshot.turns[0].assistant_content["parts"][0]["functionCall"]["id"],
            "call_real_1"
        );
    }

    /// Defensive: if a shadow turn somehow carries a synthesized
    /// `functionCall.id` (e.g. recorded by this path), replaying it via
    /// `anthropic_to_gemini_with_shadow` must strip the id before sending
    /// upstream, so Gemini never sees the internal identifier.
    #[test]
    fn non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay() {
        let store = GeminiShadowStore::with_limits(8, 4);
        let synth = synthesize_tool_call_id();
        store.record_assistant_turn(
            "prov",
            "sess",
            json!({
                "parts": [{
                    "functionCall": {
                        "id": &synth,
                        "name": "get_weather",
                        "args": { "city": "Tokyo" }
                    }
                }]
            }),
            vec![GeminiToolCallMeta::new(
                Some(synth.clone()),
                "get_weather",
                json!({ "city": "Tokyo" }),
                None::<String>,
            )],
        );

        let input = json!({
            "messages": [
                {
                    "role": "assistant",
                    "content": [
                        {
                            "type": "tool_use",
                            "id": &synth,
                            "name": "get_weather",
                            "input": { "city": "Tokyo" }
                        }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        { "type": "tool_result", "tool_use_id": &synth, "content": "sunny" }
                    ]
                }
            ]
        });
        let result =
            anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
                .unwrap();
        assert!(
            result["contents"][0]["parts"][0]["functionCall"]
                .get("id")
                .is_none(),
            "shadow replay must strip synthesized functionCall.id"
        );
        assert!(
            result["contents"][1]["parts"][0]["functionResponse"]
                .get("id")
                .is_none(),
            "functionResponse.id must also be omitted for synthesized ids"
        );
    }
}
