// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// Reference implementation:
// https://github.com/sgl-project/sglang/blob/44da737770e4bcd9bfa27751f0a0751c9b5c06e1/python/sglang/srt/function_call/qwen3_coder_detector.py

use std::collections::HashMap;

use num_traits::ToPrimitive;
use regex::Regex;
use serde_json::Value;
use uuid::Uuid;

use super::super::ToolDefinition;
use super::super::config::XmlParserConfig;
use super::parsed_value::{ParsedValue, is_integer_literal, raw_number_literal};
use super::response::{CalledFunction, ToolCallResponse, ToolCallType};

/// Build a `<start>name>(body)<end>` regex pattern. When `strict` is false,
/// missing `<end>` falls back to end-of-block so truncated input still parses
/// best-effort. Strict mode requires both fences and returns no match without
/// the close tag.
fn build_block_pattern(start: &str, end: &str, strict: bool) -> String {
    let start = regex::escape(start);
    let end = regex::escape(end);
    if strict {
        format!(r"(?s){}([^>]+)>(.*?){}", start, end)
    } else {
        format!(r"(?s){}([^>]+)>(.*?)(?:{}|$)", start, end)
    }
}

/// Strip surrounding quotes from a string if present
fn strip_quotes(s: &str) -> &str {
    let trimmed = s.trim();
    if (trimmed.starts_with('"') && trimmed.ends_with('"'))
        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
    {
        &trimmed[1..trimmed.len() - 1]
    } else {
        trimmed
    }
}

/// Check if a chunk contains the start of a xml-style tool call.
/// Format: `<tool_call><function=name><parameter=foo>...</parameter></function></tool_call>`
pub fn detect_tool_call_start_xml(chunk: &str, config: &XmlParserConfig) -> bool {
    let start_token = &config.tool_call_start_token;

    // Complete start token, or bare `<function=...>` in back-off mode (the
    // batch path treats both as tool-call starts; the streaming jail must
    // agree — see XmlParserConfig::is_bare_function_mode).
    if chunk.contains(start_token.as_str()) || config.is_bare_function_mode(chunk) {
        return true;
    }

    // Check for partial match at the end of the chunk (for streaming).
    for token in [start_token, &config.function_start_token] {
        for i in 1..token.len() {
            if chunk.ends_with(&token[..i]) {
                return true;
            }
        }
    }

    false
}

/// Find the end position of all consecutive XML-style tool calls.
/// When a model emits multiple parallel tool calls in one chunk
/// (e.g. `<tool_call>...</tool_call><tool_call>...</tool_call>`), this function
/// advances past every consecutive start→end pair so the entire group is captured
/// as a single jailed region.  Returns the position after the last `</tool_call>`
/// found, or the length of the chunk when no end token is present.
///
/// In back-off mode (see XmlParserConfig::is_bare_function_mode) the
/// function-level tokens act as the boundary so the jail releases at
/// `</function>` instead of buffering to EOS waiting for a missing
/// `</tool_call>`.
pub fn find_tool_call_end_position_xml(chunk: &str, config: &XmlParserConfig) -> usize {
    let (start_token, end_token) = if config.is_bare_function_mode(chunk) {
        (&config.function_start_token, &config.function_end_token)
    } else {
        (&config.tool_call_start_token, &config.tool_call_end_token)
    };

    // Find the first end token — if there isn't one, the call is incomplete.
    let Some(first_end) = chunk.find(end_token.as_str()) else {
        return chunk.len();
    };

    let mut cursor = first_end + end_token.len();

    // Keep consuming additional consecutive <tool_call>…</tool_call> blocks that
    // follow immediately (possibly separated by whitespace).
    loop {
        let rest = &chunk[cursor..];
        let trimmed = rest.trim_start();
        if config.is_bare_function_mode(chunk) && trimmed.starts_with(end_token.as_str()) {
            let trim_offset = rest.len() - trimmed.len();
            cursor += trim_offset + end_token.len();
            continue;
        }
        if config.is_bare_function_mode(chunk)
            && trimmed.starts_with(config.tool_call_end_token.as_str())
        {
            let trim_offset = rest.len() - trimmed.len();
            cursor += trim_offset + config.tool_call_end_token.len();
            break;
        }
        if !trimmed.starts_with(start_token.as_str()) {
            break;
        }
        // Compute where the trimmed slice starts in the original chunk.
        let trim_offset = rest.len() - trimmed.len();
        let search_from = cursor + trim_offset + start_token.len();
        if let Some(end_pos) = chunk[search_from..].find(end_token.as_str()) {
            cursor = search_from + end_pos + end_token.len();
        } else {
            // Next block is incomplete — stop here; the jail will wait for more data.
            break;
        }
    }

    cursor
}

/// Try to parse Qwen3Coder formatted tool calls from a message.
/// Format: `<tool_call><function=name><parameter=key>value</parameter></function></tool_call>`
/// Returns (parsed_tool_calls, normal_text_content)
pub fn try_tool_call_parse_xml(
    message: &str,
    config: &XmlParserConfig,
    tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<(Vec<ToolCallResponse>, Option<String>)> {
    // Qwen3-Coder-style passthrough: if the function-start token is absent
    // anywhere in the input, the reference parser returns the raw input as
    // content with no tool calls. Gated so it only fires for parsers that opt
    // in (e.g. qwen3_coder, nemotron_nano).
    if config.passthrough_when_no_function
        && !message.contains(config.function_start_token.as_str())
        && !message.contains(config.tool_call_start_token.as_str())
    {
        return Ok((vec![], Some(message.to_string())));
    }

    // Back-off: outer wrapper missing but function/invoke tags are present —
    // parse the whole input as a single tool-call block. Qwen3-Coder uses this
    // in its reference parser. MiniMax uses the same path to recover complete
    // inner invokes when the outer wrapper opener is missing.
    //
    // Streaming recovery needs the orphan outer close marker as well as the
    // inner close marker. Otherwise a split `</tool_call>` / MiniMax close can
    // arrive after the jail has already released.
    if config.is_bare_function_mode(message)
        && (message.contains(config.function_end_token.as_str()) || config.allow_eof_recovery)
        && (message.contains(config.tool_call_end_token.as_str()) || config.allow_eof_recovery)
    {
        let calls = parse_tool_call_block(message, config, tools).unwrap_or_default();
        if !calls.is_empty() {
            // Preserve narration before the first `<function=...>` tag so
            // streaming output isn't dropped on the back-off path.
            let prefix = message
                .split_once(config.function_start_token.as_str())
                .map(|(p, _)| p.to_string())
                .unwrap_or_default();
            return Ok((calls, Some(prefix)));
        }
    }

    if config.strict_match
        && !message.contains(config.tool_call_start_token.as_str())
        && let Some(prefix) = prefix_before_orphan_xml_marker(message, config)
    {
        return Ok((vec![], Some(prefix)));
    }

    let (normal_text, tool_calls) = extract_tool_calls(message, config, tools)?;

    let normal_content = if normal_text.is_empty() {
        Some("".to_string())
    } else {
        Some(normal_text)
    };

    Ok((tool_calls, normal_content))
}

/// Extract tool calls and normal text from message.
fn extract_tool_calls(
    text: &str,
    config: &XmlParserConfig,
    tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<(String, Vec<ToolCallResponse>)> {
    let mut normal_text = String::new();
    let mut calls = Vec::new();
    let mut cursor = 0;

    let start_token = &config.tool_call_start_token;
    let end_token = &config.tool_call_end_token;

    while cursor < text.len() {
        // Find next tool call start.
        if let Some(start_pos) = text[cursor..].find(start_token.as_str()) {
            let abs_start = cursor + start_pos;
            let gap = &text[cursor..abs_start];
            if let Some((prefix, mut recovered_calls)) =
                recover_bare_xml_calls_in_span(gap, config, tools)?
            {
                if calls.is_empty() {
                    normal_text.push_str(&prefix);
                }
                calls.append(&mut recovered_calls);
            } else if calls.is_empty() {
                normal_text.push_str(gap);
            }

            // Qwen3-Coder-style templates allow natural language before the
            // tool call, but text after the tool-call block is not response
            // content. Keep scanning for additional calls, but only surface
            // normal text that precedes the first parsed call.

            // Find the corresponding end token.
            if let Some(end_pos) = text[abs_start..].find(end_token.as_str()) {
                let abs_end = abs_start + end_pos + end_token.len();
                let block = &text[abs_start..abs_end];

                // Parse this tool call block.
                if let Ok(mut parsed_calls) = parse_tool_call_block(block, config, tools) {
                    calls.append(&mut parsed_calls);
                }

                cursor = abs_end;
            } else {
                // Recovery: outer end token absent (max_tokens / EOS truncation).
                // Gated on `allow_eof_recovery` so streaming early-exit doesn't
                // fire mid-stream. Strict XML families still require paired
                // inner invoke/parameter fences before a call is recovered.
                // Recovery also requires the trailing slice to contain a
                // function-start opener — structural signal that a real tool
                // call was emitted, so plain text starting with `<tool_call>`
                // is preserved verbatim.
                let block = &text[abs_start..];
                let function_start = &config.function_start_token;
                let looks_like_tool_call = block.contains(function_start.as_str())
                    || block.contains(config.parameter_start_token.as_str());
                if config.allow_eof_recovery
                    && looks_like_tool_call
                    && let Ok(mut parsed_calls) = parse_tool_call_block(block, config, tools)
                    && !parsed_calls.is_empty()
                {
                    calls.append(&mut parsed_calls);
                    break;
                }
                if calls.is_empty() && !looks_like_tool_call {
                    normal_text.push_str(&text[abs_start..]);
                }
                break;
            }
        } else {
            // No more tool calls.
            let gap = &text[cursor..];
            if let Some((prefix, mut recovered_calls)) =
                recover_bare_xml_calls_in_span(gap, config, tools)?
            {
                if calls.is_empty() {
                    normal_text.push_str(&prefix);
                }
                calls.append(&mut recovered_calls);
            } else if calls.is_empty() {
                normal_text.push_str(gap);
            }
            break;
        }
    }

    let normal_text = if calls.is_empty() {
        normal_text.trim().to_string()
    } else {
        normal_text
    };
    Ok((normal_text, calls))
}

fn prefix_before_orphan_xml_marker(text: &str, config: &XmlParserConfig) -> Option<String> {
    [
        config.tool_call_end_token.as_str(),
        config.function_start_token.as_str(),
        config.function_end_token.as_str(),
        config.parameter_start_token.as_str(),
        config.parameter_end_token.as_str(),
    ]
    .into_iter()
    .filter_map(|marker| text.find(marker))
    .min()
    .map(|idx| text[..idx].trim().to_string())
}

fn recover_bare_xml_calls_in_span(
    span: &str,
    config: &XmlParserConfig,
    tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Option<(String, Vec<ToolCallResponse>)>> {
    if !config.backoff_when_no_wrapper {
        return Ok(None);
    }

    let Some(marker_idx) = span.find(config.function_start_token.as_str()) else {
        return Ok(None);
    };

    let tail = &span[marker_idx..];
    let has_inner_close = tail.contains(config.function_end_token.as_str());
    let has_outer_close = tail.contains(config.tool_call_end_token.as_str());
    if (!has_inner_close || !has_outer_close) && !config.allow_eof_recovery {
        return Ok(None);
    }

    let calls = parse_tool_call_block(tail, config, tools)?;
    if calls.is_empty() {
        return Ok(None);
    }

    tracing::warn!(
        why = "bare_function_gap_recovery",
        recovered_calls = calls.len(),
        recovered_bytes = tail.len(),
        kept_prefix_bytes = marker_idx,
        "XML recovery: recovered complete bare function block(s) before a later outer wrapper"
    );
    Ok(Some((span[..marker_idx].to_string(), calls)))
}

/// Parse a single tool call block
/// Format: `<tool_call><function=name><parameter=key>value</parameter>...</function></tool_call>`
fn parse_tool_call_block(
    block: &str,
    config: &XmlParserConfig,
    tools: Option<&[ToolDefinition]>,
) -> anyhow::Result<Vec<ToolCallResponse>> {
    // Strict-match families (e.g. minimax_m2) require paired fences; lenient
    // families fall back to end-of-block when the close tag is missing.
    let function_regex = Regex::new(&build_block_pattern(
        &config.function_start_token,
        &config.function_end_token,
        config.strict_match,
    ))?;
    let parameter_regex = Regex::new(&build_block_pattern(
        &config.parameter_start_token,
        &config.parameter_end_token,
        config.strict_match,
    ))?;

    let mut results = Vec::new();

    // Find all function blocks.
    for func_cap in function_regex.captures_iter(block) {
        let function_name_raw = func_cap.get(1).map(|m| m.as_str().trim()).unwrap_or("");
        let function_name = strip_quotes(function_name_raw);
        let function_body = func_cap.get(2).map(|m| m.as_str()).unwrap_or("");

        if function_name.is_empty() {
            continue;
        }

        // Get parameter config for this function
        let param_config = get_arguments_config(function_name, tools);

        // Parse parameters from the function body.
        let mut parameters: HashMap<String, ParsedValue> = HashMap::new();

        for param_cap in parameter_regex.captures_iter(function_body) {
            let param_name_raw = param_cap.get(1).map(|m| m.as_str().trim()).unwrap_or("");
            let param_name = strip_quotes(param_name_raw);
            let param_value = param_cap.get(2).map(|m| m.as_str()).unwrap_or("");

            if !param_name.is_empty() {
                let parsed_value =
                    convert_param_value(param_value, param_name, &param_config, function_name);
                parameters.insert(param_name.to_string(), parsed_value);
            }
        }

        // Create tool call response.
        let arguments_json = serde_json::to_string(&parameters)?;

        let tool_call = ToolCallResponse {
            id: format!("call-{}", Uuid::new_v4()),
            tp: ToolCallType::Function,
            function: CalledFunction {
                name: function_name.to_string(),
                arguments: arguments_json,
            },
        };

        results.push(tool_call);
    }

    Ok(results)
}

/// Extract argument configuration for a function from the tool definitions.
/// Returns a HashMap of parameter names to their schema definitions.
fn get_arguments_config(
    func_name: &str,
    tools: Option<&[ToolDefinition]>,
) -> HashMap<String, Value> {
    let Some(tools) = tools else {
        return HashMap::new();
    };

    for tool in tools {
        if tool.name == func_name {
            if let Some(params) = &tool.parameters {
                // Try to extract "properties" from the parameters schema
                if let Some(properties) = params.get("properties") {
                    if let Some(props_obj) = properties.as_object() {
                        return props_obj
                            .iter()
                            .map(|(k, v)| (k.clone(), v.clone()))
                            .collect();
                    }
                } else if let Some(params_obj) = params.as_object() {
                    // If no "properties" field, treat the whole thing as the config
                    return params_obj
                        .iter()
                        .map(|(k, v)| (k.clone(), v.clone()))
                        .collect();
                }
            }
            return HashMap::new();
        }
    }

    tracing::warn!("Tool '{}' is not defined in the tools list.", func_name);
    HashMap::new()
}

/// Convert parameter value based on its type in the schema.
/// This matches the behavior of the Python implementation.
/// Converts a string parameter value from XML into a typed JSON Value.
///
/// # Examples
///
/// **String types:**
/// ```text
/// Input:  param_value="hello world", param_type="string"
/// Output: Value::String("hello world")
/// ```
///
/// ```text
/// Input:  param_value="42", param_type="string"
/// Output: Value::String("42")
/// ```
///
/// **Integer types:**
/// ```text
/// Input:  param_value="42", param_type="integer"
/// Output: Value::Number(42)
///
/// Input:  param_value="not_a_number", param_type="int"
/// Output: Value::String("not_a_number")  // Falls back to string with warning
/// ```
///
/// **Float/Number types:**
/// ```text
/// Input:  param_value="3.14", param_type="number"
/// Output: Value::Number(3.14)
///
/// Input:  param_value="42.0", param_type="float"
/// Output: Value::Number(42)  // Whole numbers stored as integers
/// ```
///
/// **Boolean types:**
/// ```text
/// Input:  param_value="true", param_type="boolean"
/// Output: Value::Bool(true)
///
/// Input:  param_value="yes", param_type="bool"
/// Output: Value::Bool(false)  // Falls back to false with warning
/// ```
///
/// **Complex types (objects/arrays):**
/// ```text
/// Input:  param_value='{"key": "value"}', param_type="object"
/// Output: Value::Object({"key": "value"})
///
/// Input:  param_value="[1, 2, 3]", param_type="array"
/// Output: Value::Array([1, 2, 3])
///
/// Input:  param_value="{'key': 'value'}", param_type="dict"
/// Output: Value::Object({"key": "value"})  // Uses ast.literal_eval-style parsing
/// ```
///
/// **Special cases:**
/// ```text
/// Input:  param_value="null", param_type=<any>
/// Output: Value::Null  // Handled before type checking
///
/// Input:  param_value="&lt;tag&gt;", param_type="string"
/// Output: Value::String("<tag>")  // HTML entities are unescaped
///
/// Input:  param_value="123", param_type=<undefined/not in schema>
/// Output: Value::String("123")  // Unknown params returned as strings
/// ```
///
/// # Arguments
///
/// * `param_value` - The raw string value from XML parameter
/// * `param_name` - The parameter name (used for schema lookup and error messages)
/// * `param_config` - Schema defining expected types for each parameter
/// * `func_name` - The function/tool name (used for error messages)
///
/// # Type Aliases
///
/// The function recognizes various type name aliases:
/// - Strings: "string", "str", "text", "varchar", "char", "enum"
/// - Integers: "int", "integer", "int32", "int64", "uint", "long", "short", "unsigned"
/// - Numbers: "number", "num", "float", "float32", "float64", "double"
/// - Booleans: "boolean", "bool", "binary"
/// - Objects: "object", "dict", "dictionary"
/// - Arrays: "array", "arr", "list"
fn convert_param_value(
    param_value: &str,
    param_name: &str,
    param_config: &HashMap<String, Value>,
    func_name: &str,
) -> ParsedValue {
    // HTML unescape and trim
    let param_value = html_unescape(param_value.trim());

    // Handle null
    if param_value.to_lowercase() == "null" {
        return Value::Null.into();
    }

    // Check if parameter is in config
    if !param_config.contains_key(param_name) {
        tracing::debug!(
            "Parsed parameter '{}' is not defined in the tool parameters for tool '{}', directly returning the string value.",
            param_name,
            func_name
        );
        return Value::String(param_value).into();
    }

    // Get the type from schema.
    // If a parameter uses "anyOf"/"oneOf" instead of a direct "type", there is no
    // top-level "type" key. Treat it as "object" so the value goes through JSON
    // parsing rather than being returned as a double-encoded string.
    let param_schema = param_config.get(param_name);
    let param_type = param_schema
        .and_then(|v| v.get("type"))
        .and_then(|t| t.as_str())
        .map(|t| t.to_lowercase())
        .unwrap_or_else(|| {
            if param_schema
                .map(|v| v.get("anyOf").is_some() || v.get("oneOf").is_some())
                .unwrap_or(false)
            {
                "object".to_string()
            } else {
                "string".to_string()
            }
        });

    // The follow `match` block follows this rough pattern for each block:
    // 1. Match `param_type` against predefined string representations of each type,
    // 2. Parse the string value and convert it to the appropriate Rust JSON Value type.
    // Each branch handles a category of type aliases (e.g., "int"/"integer"/"int32" all map to i64).
    // If parsing fails, we log a warning and fall back to returning the value as a string.
    match param_type.as_str() {
        // String types: Return value as-is (already HTML-unescaped above)
        "string" | "str" | "text" | "varchar" | "char" | "enum" => {
            Value::String(param_value).into()
        }

        // Integer types: Parse as i64, fall back to string on error.
        // Matches: "int", "integer", "int32", "uint", "unsigned", "long", "short", etc.
        t if t.starts_with("int")
            || t.starts_with("uint")
            || t.starts_with("long")
            || t.starts_with("short")
            || t.starts_with("unsigned") =>
        {
            match param_value.parse::<i64>() {
                Ok(int_val) => Value::Number(int_val.into()).into(),
                Err(_) => {
                    tracing::warn!(
                        "Parsed value '{}' of parameter '{}' is not an integer in tool '{}', degenerating to string.",
                        param_value,
                        param_name,
                        func_name
                    );
                    Value::String(param_value).into()
                }
            }
        }

        // Float/Number types: Parse integer-looking tokens before f64 to avoid
        // precision loss above f64's exact integer range.
        // Matches: "number", "num", "float", "float32", "float64", "double", etc.
        // Note: Whole numbers (e.g., 42.0) are stored as integers for better JSON compatibility
        // when they fit in i64. Larger finite whole numbers must not be cast with `as i64`,
        // which saturates to i64::MIN/MAX and corrupts model-emitted arguments.
        t if t.starts_with("num") || t.starts_with("float") => {
            if is_integer_literal(&param_value) {
                if let Ok(int_val) = param_value.parse::<i64>() {
                    Value::Number(int_val.into()).into()
                } else if let Some(raw) = raw_number_literal(&param_value) {
                    raw
                } else {
                    Value::String(param_value).into()
                }
            } else {
                match param_value.parse::<f64>() {
                    Ok(float_val) => {
                        if float_val.fract() == 0.0 && float_val.is_finite() {
                            if let Some(int_val) = float_val.to_i64() {
                                Value::Number(int_val.into()).into()
                            } else if let Some(raw) = raw_number_literal(&param_value) {
                                raw
                            } else {
                                Value::String(param_value).into()
                            }
                        } else if let Some(num) = serde_json::Number::from_f64(float_val) {
                            Value::Number(num).into()
                        } else {
                            tracing::warn!(
                                "Parsed value '{}' of parameter '{}' is not a valid float in tool '{}', degenerating to string.",
                                param_value,
                                param_name,
                                func_name
                            );
                            Value::String(param_value).into()
                        }
                    }
                    Err(_) => {
                        tracing::warn!(
                            "Parsed value '{}' of parameter '{}' is not a float in tool '{}', degenerating to string.",
                            param_value,
                            param_name,
                            func_name
                        );
                        Value::String(param_value).into()
                    }
                }
            }
        }

        // Boolean types: Only "true" or "false" (case-insensitive) are valid.
        // Any other value defaults to false with a warning.
        "boolean" | "bool" | "binary" => {
            let lower_val = param_value.to_lowercase();
            if lower_val != "true" && lower_val != "false" {
                tracing::warn!(
                    "Parsed value '{}' of parameter '{}' is not a boolean (`true` or `false`) in tool '{}', degenerating to false.",
                    param_value,
                    param_name,
                    func_name
                );
            }
            Value::Bool(lower_val == "true").into()
        }

        // Complex types (objects/arrays): Try JSON parsing, then fall back to Python-style
        // `ast.literal_eval` (or our own barebones version of it for the purposes of this
        // parser).
        // Matches: "object", "array", "arr", "dict", "dictionary", "list", etc.
        // This handles both JSON syntax ({"a": 1}) and Python syntax ({'a': 1}).
        t if t == "object"
            || t == "array"
            || t == "arr"
            || t.starts_with("dict")
            || t.starts_with("list") =>
        {
            // Try JSON parsing first (standard JSON with double quotes).
            if let Ok(json_val) = serde_json::from_str::<Value>(&param_value) {
                return json_val.into();
            }

            tracing::warn!(
                "Parsed value '{}' of parameter '{}' cannot be parsed with json.loads in tool '{}', will try other methods to parse it.",
                param_value,
                param_name,
                func_name
            );

            // Try `ast.literal_eval` equivalent (handles Python-style single quotes, etc.).
            if let Ok(json_val) = try_literal_eval(&param_value) {
                return json_val.into();
            }

            tracing::warn!(
                "Parsed value '{}' of parameter '{}' cannot be converted via Python `ast.literal_eval()` in tool '{}', degenerating to string.",
                param_value,
                param_name,
                func_name
            );
            Value::String(param_value).into()
        }

        // Unknown/custom types: Attempt best-effort parsing via `literal_eval`.
        // This allows for flexible type names while still trying to parse structured data
        _ => {
            // Unknown type, try `literal_eval`.
            if let Ok(json_val) = try_literal_eval(&param_value) {
                return json_val.into();
            }

            tracing::warn!(
                "Parsed value '{}' of parameter '{}' cannot be converted via Python `ast.literal_eval()` in tool '{}', degenerating to string.",
                param_value,
                param_name,
                func_name
            );
            Value::String(param_value).into()
        }
    }
}

/// Try to parse a value similar to Python's ast.literal_eval.
/// This is a simplified version that handles common cases.
fn try_literal_eval(s: &str) -> Result<Value, ()> {
    // First try standard JSON
    if let Ok(val) = serde_json::from_str::<Value>(s) {
        return Ok(val);
    }

    // Try to handle Python-style literals (single quotes, True/False/None)
    let normalized = s
        .replace('\'', "\"") // Replace single quotes with double quotes
        .replace("True", "true")
        .replace("False", "false")
        .replace("None", "null");

    serde_json::from_str::<Value>(&normalized).map_err(|_| ())
}

/// Safely parse a value - tries JSON, then falls back to string.
/// Mimics SGLang's `_safe_val` function in spirit.
/// NOTE: This function is deprecated and kept for reference. Use convert_param_value instead.
#[allow(dead_code)]
fn safe_parse_value(raw: &str) -> serde_json::Value {
    // HTML unescape
    let unescaped = html_unescape(raw.trim());

    if let Ok(value) = serde_json::from_str::<serde_json::Value>(&unescaped) {
        return value;
    }

    if let Ok(num) = unescaped.parse::<i64>() {
        return serde_json::Value::Number(num.into());
    }

    if let Ok(num) = unescaped.parse::<f64>()
        && let Some(num_val) = serde_json::Number::from_f64(num)
    {
        return serde_json::Value::Number(num_val);
    }

    match unescaped.to_lowercase().as_str() {
        "true" => return serde_json::Value::Bool(true),
        "false" => return serde_json::Value::Bool(false),
        "null" | "none" => return serde_json::Value::Null,
        _ => {}
    }

    // Default to string, stripping newlines from start and end.
    serde_json::Value::String(unescaped.trim_matches('\n').to_string())
}

/// Simple HTML unescape for common entities.
fn html_unescape(s: &str) -> String {
    s.replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&amp;", "&")
        .replace("&quot;", "\"")
        .replace("&#x27;", "'")
        .replace("&#39;", "'")
}

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

    #[test] // helper
    fn test_detect_tool_call_start() {
        let config = XmlParserConfig::default();
        assert!(detect_tool_call_start_xml("<tool_call>", &config));
        assert!(detect_tool_call_start_xml("text <tool_call>", &config));
        assert!(detect_tool_call_start_xml("<tool_c", &config)); // Partial match
        assert!(detect_tool_call_start_xml("<", &config)); // Partial match
        assert!(!detect_tool_call_start_xml("no tool call here", &config));
        assert!(!detect_tool_call_start_xml("toolcall", &config));
    }

    #[test] // helper
    fn test_find_tool_call_end_position() {
        let config = XmlParserConfig::default();
        let text = "<tool_call><function=test></function></tool_call>more text";
        let pos = find_tool_call_end_position_xml(text, &config);
        assert_eq!(pos, 49); // Position after </tool_call>
        assert_eq!(&text[pos..], "more text");

        let text_no_end = "<tool_call><function=test>";
        let pos = find_tool_call_end_position_xml(text_no_end, &config);
        assert_eq!(pos, text_no_end.len());
    }

    /// Regression test for issue #6822: parallel tool calls in a single chunk must
    /// all be captured by find_tool_call_end_position_xml so that the jail passes the
    /// entire group to extract_tool_calls rather than emitting the second (and later)
    /// calls as raw trailing text.
    #[test] // TOOLCALLING.batch.2, helper
    fn test_find_tool_call_end_position_parallel_calls() {
        let config = XmlParserConfig::default();

        // Two parallel calls with no whitespace between them.
        let two_calls = "<tool_call><function=foo><parameter=x>1</parameter></function></tool_call>\
                         <tool_call><function=bar><parameter=y>2</parameter></function></tool_call>\
                         trailing";
        let pos = find_tool_call_end_position_xml(two_calls, &config);
        // Everything up to (but not including) "trailing" should be captured.
        assert!(
            &two_calls[..pos].ends_with("</tool_call>"),
            "should end at last </tool_call>, got: {:?}",
            &two_calls[..pos]
        );
        assert_eq!(&two_calls[pos..], "trailing");

        // Three parallel calls separated by whitespace / newlines.
        let three_calls = "<tool_call><function=a></function></tool_call>\n\
                           <tool_call><function=b></function></tool_call>\n\
                           <tool_call><function=c></function></tool_call> done";
        let pos3 = find_tool_call_end_position_xml(three_calls, &config);
        assert!(
            &three_calls[..pos3].ends_with("</tool_call>"),
            "should end at last </tool_call>, got: {:?}",
            &three_calls[..pos3]
        );
        assert_eq!(three_calls[pos3..].trim(), "done");

        // Incomplete second call — should stop after the first complete one.
        let incomplete = "<tool_call><function=a></function></tool_call>\
                          <tool_call><function=b>"; // no </tool_call>
        let pos_inc = find_tool_call_end_position_xml(incomplete, &config);
        // The first complete call ends at position 46 (length of first block).
        let first_end = "<tool_call><function=a></function></tool_call>".len();
        assert_eq!(
            pos_inc, first_end,
            "should stop at end of first complete call when second is incomplete"
        );
    }

    #[test]
    fn test_number_coercion_does_not_saturate_large_whole_values() {
        let input = r#"<tool_call>
<function=set_limit>
<parameter=count>100000000000000000000</parameter>
<parameter=precise_count>9007199254740993</parameter>
</function>
</tool_call>"#;
        let tools = vec![ToolDefinition {
            name: "set_limit".to_string(),
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "count": {"type": "number"},
                    "precise_count": {"type": "number"}
                }
            })),
            strict: None,
        }];

        let (calls, normal) =
            try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
        let args: HashMap<String, Box<serde_json::value::RawValue>> =
            serde_json::from_str(&calls[0].function.arguments).unwrap();

        assert_eq!(normal, Some("".to_string()));
        assert_eq!(calls[0].function.name, "set_limit");
        assert_eq!(args["count"].get(), "100000000000000000000");
        assert_eq!(args["precise_count"].get(), "9007199254740993");
    }

    #[rstest] // helper
    #[case(r#"{"key": "value"}"#, serde_json::json!({"key": "value"}), "JSON object")]
    #[case(r#"[1, 2, 3]"#, serde_json::json!([1, 2, 3]), "JSON array")]
    #[case("42", serde_json::json!(42), "integer")]
    #[case("3.15", serde_json::json!(3.15), "float")]
    #[case("true", serde_json::json!(true), "boolean true")]
    #[case("false", serde_json::json!(false), "boolean false")]
    #[case("null", serde_json::json!(null), "null")]
    #[case("hello", serde_json::json!("hello"), "unquoted string")]
    #[case("  text  ", serde_json::json!("text"), "trimmed string")]
    fn test_safe_parse_value(
        #[case] input: &str,
        #[case] expected: serde_json::Value,
        #[case] _description: &str,
    ) {
        assert_eq!(safe_parse_value(input), expected);
    }

    #[rstest] // helper
    #[case("&lt;div&gt;", "<div>", "HTML tags")]
    #[case("a &amp; b", "a & b", "ampersand")]
    #[case("&quot;quoted&quot;", "\"quoted\"", "quotes")]
    fn test_html_unescape(#[case] input: &str, #[case] expected: &str, #[case] _description: &str) {
        assert_eq!(html_unescape(input), expected);
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.1 in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.yaml.
    #[test] // TOOLCALLING.batch.1
    fn test_parse_simple_tool_call() {
        let input = r#"<tool_call>
<function=execute_bash>
<parameter=command>
pwd && ls
</parameter>
</function>
</tool_call>"#;

        let (calls, normal) =
            try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "execute_bash");
        assert_eq!(normal, Some("".to_string()));

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert_eq!(args["command"], "pwd && ls");
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.1, TOOLCALLING.batch.7.d in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.7.yaml, tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.yaml.
    #[test] // TOOLCALLING.batch.1, TOOLCALLING.batch.7
    fn test_parse_multiple_parameters() {
        let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
San Francisco
</parameter>
<parameter=state>
CA
</parameter>
<parameter=unit>
fahrenheit
</parameter>
</function>
</tool_call>"#;

        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "get_weather");

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert_eq!(args["city"], "San Francisco");
        assert_eq!(args["state"], "CA");
        assert_eq!(args["unit"], "fahrenheit");
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.8.c in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.8.yaml.
    #[test] // TOOLCALLING.batch.8
    fn test_parse_with_normal_text() {
        let input = r#"I'll help you with that. <tool_call>
<function=get_weather>
<parameter=city>
Dallas
</parameter>
</function>
</tool_call> Let me check that for you."#;

        let (calls, normal) =
            try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "get_weather");
        assert_eq!(normal, Some("I'll help you with that. ".to_string()));
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.2.b in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.2.yaml.
    #[test] // TOOLCALLING.batch.2
    fn test_parse_multiple_tool_calls() {
        let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
Dallas
</parameter>
</function>
</tool_call>
<tool_call>
<function=get_weather>
<parameter=city>
Orlando
</parameter>
</function>
</tool_call>"#;

        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 2);
        assert_eq!(calls[0].function.name, "get_weather");
        assert_eq!(calls[1].function.name, "get_weather");

        let args0: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        let args1: serde_json::Value = serde_json::from_str(&calls[1].function.arguments).unwrap();
        assert_eq!(args0["city"], "Dallas");
        assert_eq!(args1["city"], "Orlando");
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.7.d in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.7.yaml.
    #[test] // TOOLCALLING.batch.7
    fn test_parse_json_parameter_value() {
        // With schema-aware parsing, we need to provide a schema to parse JSON objects
        let tools = vec![ToolDefinition {
            name: "process_data".to_string(),
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "config": {"type": "object"}
                }
            })),
            strict: None,
        }];

        let input = r#"<tool_call>
<function=process_data>
<parameter=config>
{"setting": "value", "count": 42}
</parameter>
</function>
</tool_call>"#;

        let (calls, _) =
            try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
        assert_eq!(calls.len(), 1);

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert!(args["config"].is_object());
        assert_eq!(args["config"]["setting"], "value");
        assert_eq!(args["config"]["count"], 42);
    }

    #[test] // TOOLCALLING.batch.3
    fn test_parse_no_tool_calls() {
        let input = "This is just normal text without any tool calls.";
        let (calls, normal) =
            try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 0);
        assert_eq!(normal, Some(input.to_string()));
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.4.d in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.4.yaml.
    #[test] // TOOLCALLING.batch.4
    fn test_parse_malformed_tool_call() {
        let input = r#"<tool_call>
<function=incomplete>
<parameter=test>
value
</tool_call>"#;

        // Should handle gracefully - might parse or return empty
        let result = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None);
        assert!(result.is_ok());
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.4.d in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.4.yaml.
    #[test] // TOOLCALLING.batch.4
    fn test_parse_missing_parameter_closing_tag() {
        let input = r#"<tool_call>
<function=execute_bash>
<parameter=command>
ls -la
</function>
</tool_call>"#;

        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "execute_bash");

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert_eq!(args["command"], "ls -la");
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.4.d in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.4.yaml.
    #[test] // TOOLCALLING.batch.4
    fn test_parse_missing_function_closing_tag() {
        let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
Boston
</parameter>
</tool_call>"#;

        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "get_weather");

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert_eq!(args["city"], "Boston");
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.4.d in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.4.yaml.
    #[test] // TOOLCALLING.batch.4
    fn test_parse_missing_both_closing_tags() {
        let input = r#"<tool_call>
<function=run_query>
<parameter=sql>
SELECT * FROM users
</tool_call>"#;

        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "run_query");

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        // This matches the original SGLang python implementation.
        assert_eq!(args["sql"], "SELECT * FROM users\n</tool_call>");
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.4.d in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.4.yaml.
    #[test] // TOOLCALLING.batch.4
    fn test_parse_multiple_parameters_missing_closing_tags() {
        let input = r#"<tool_call>
<function=search>
<parameter=query>
rust programming
<parameter=limit>
10
</function>
</tool_call>"#;

        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "search");

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        // This matches the original SGLang python implementation.
        assert_eq!(args["query"], "rust programming\n<parameter=limit>\n10");
    }

    // Recovery for missing outer </tool_call> (max_tokens / EOS truncation):
    // when the inner function block is well-formed, treat EOF as the end
    // token and extract the call. Recovery is gated on a function-start
    // opener in the trailing slice so plain text that happens to start with
    // `<tool_call>` is preserved verbatim.
    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.5.a in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.5.yaml.
    #[test] // TOOLCALLING.batch.5 — qwen3_coder
    fn test_parse_qwen3_no_outer_close_recovers() {
        let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
NYC
</parameter>
</function>"#;

        let config = XmlParserConfig {
            allow_eof_recovery: true,
            ..XmlParserConfig::default()
        };
        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "get_weather");
        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert_eq!(args["city"], "NYC");
    }

    // Streaming-jail symmetry: when `<function=...>` is partial (no
    // `</function>` yet) and recovery is OFF, back-off must NOT fire — the
    // lenient `|$` regex would otherwise match the truncated content and
    // cause `should_exit_jail_early` to release the jail before the closing
    // tag arrives, leaking the rest of the call as text. Recovery ON
    // (finalize path) is still allowed to recover the truncated call.
    #[test]
    fn test_parse_qwen3_bare_function_partial_no_recovery_returns_no_calls() {
        let input = "<function=get_weather>\n<parameter=city>\nNY";
        let config = XmlParserConfig {
            backoff_when_no_wrapper: true,
            // allow_eof_recovery=false (streaming jail path)
            ..XmlParserConfig::default()
        };
        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert!(
            calls.is_empty(),
            "back-off must not fire on partial input without recovery (streaming jail leak)",
        );
    }

    #[test]
    fn test_parse_qwen3_bare_function_complete_without_outer_close_waits_in_streaming() {
        // `</function>` alone is not enough for streaming recovery: the orphan
        // `</tool_call>` may still arrive in a later chunk and must stay jailed.
        let input = "<function=get_weather>\n<parameter=city>\nNYC\n</parameter>\n</function>";
        let config = XmlParserConfig {
            backoff_when_no_wrapper: true,
            ..XmlParserConfig::default()
        };
        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert!(calls.is_empty());
    }

    #[test]
    fn test_parse_qwen3_bare_function_outer_close_streaming_recovers() {
        let input = "<function=get_weather>\n<parameter=city>\nNYC\n</parameter>\n</function>\n</tool_call>";
        let config = XmlParserConfig {
            backoff_when_no_wrapper: true,
            ..XmlParserConfig::default()
        };
        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "get_weather");
    }

    #[test]
    fn test_parse_qwen3_bare_function_partial_with_recovery_recovers() {
        // Finalize path: recovery ON allows truncated function block to
        // surface a (potentially incomplete) call rather than being dropped.
        let input = "<function=get_weather>\n<parameter=city>\nNY";
        let config = XmlParserConfig {
            backoff_when_no_wrapper: true,
            allow_eof_recovery: true,
            ..XmlParserConfig::default()
        };
        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "get_weather");
    }

    // Qwen3-Coder-style XML treats text after the first parsed tool call as
    // non-content, including after EOF recovery.
    #[test]
    fn test_parse_qwen3_no_outer_close_drops_suffix() {
        let input = "<tool_call>\n<function=get_weather>\n<parameter=city>\nNYC\n</parameter>\n</function>\nTRAILING NOTE";

        let config = XmlParserConfig {
            allow_eof_recovery: true,
            ..XmlParserConfig::default()
        };
        let (calls, normal) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(normal, Some("".to_string()));
    }

    #[test] // TOOLCALLING.batch.5.a — minimax_m2
    fn test_parse_minimax_m2_no_outer_close_recovers_complete_inner_call() {
        // Finalize recovery may accept a missing outer MiniMax wrapper close,
        // but strict_match still requires complete inner invoke/parameter
        // fences before surfacing a call.
        let config = XmlParserConfig {
            tool_call_start_token: "<minimax:tool_call>".to_string(),
            tool_call_end_token: "</minimax:tool_call>".to_string(),
            function_start_token: "<invoke name=".to_string(),
            function_end_token: "</invoke>".to_string(),
            parameter_start_token: "<parameter name=".to_string(),
            parameter_end_token: "</parameter>".to_string(),
            allow_eof_recovery: true,
            strict_match: true,
            passthrough_when_no_function: false,
            backoff_when_no_wrapper: true,
        };
        let input = r#"<minimax:tool_call><invoke name="get_weather"><parameter name="city">NYC</parameter></invoke>"#;

        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "get_weather");
        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert_eq!(args["city"], "NYC");
    }

    #[test] // helper
    fn test_schema_aware_type_conversion() {
        // This test matches the Python test_parse_streaming_increment_multiple_parameters
        // from the diff, showing schema-aware type conversion
        let tools = vec![ToolDefinition {
            name: "multi_param_func".to_string(),
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "param1": {"type": "string"},
                    "param2": {"type": "float"},
                    "param3": {"type": "integer"},
                    "param4": {"type": "boolean"},
                    "param5": {"type": "object"},
                    "param6": {"type": "array"},
                    "param7": {"type": "null"},
                    "param8": {"type": "other_type"}
                },
                "required": ["param1", "param2", "param3", "param4", "param5", "param6", "param7", "param8"]
            })),
            strict: None,
        }];

        let input = r#"<tool_call>
<function=multi_param_func>
<parameter=param1>42</parameter>
<parameter=param2>41.9</parameter>
<parameter=param3>42</parameter>
<parameter=param4>true</parameter>
<parameter=param5>{"key": "value"}</parameter>
<parameter=param6>[1, 2, 3]</parameter>
<parameter=param7>null</parameter>
<parameter=param8>{'arg1': 3, 'arg2': [1, 2]}</parameter>
</function>
</tool_call>"#;

        let (calls, _) =
            try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "multi_param_func");

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();

        // param1 is type "string" so "42" stays as string
        assert_eq!(args["param1"], "42");

        // param2 is type "float" so 41.9 is parsed as float
        assert_eq!(args["param2"], 41.9);

        // param3 is type "integer" so 42 is parsed as integer
        assert_eq!(args["param3"], 42);

        // param4 is type "boolean" so "true" is parsed as bool
        assert_eq!(args["param4"], true);

        // param5 is type "object" so JSON is parsed
        assert_eq!(args["param5"], serde_json::json!({"key": "value"}));

        // param6 is type "array" so JSON array is parsed
        assert_eq!(args["param6"], serde_json::json!([1, 2, 3]));

        // param7 is type "null" so "null" is parsed as null
        assert_eq!(args["param7"], serde_json::Value::Null);

        // param8 is other_type, uses literal_eval which converts Python-style dict
        assert_eq!(
            args["param8"],
            serde_json::json!({"arg1": 3, "arg2": [1, 2]})
        );
    }

    #[test] // helper
    fn test_schema_aware_type_conversion_fallback() {
        // Test that invalid values fall back to strings with warnings
        let tools = vec![ToolDefinition {
            name: "test_func".to_string(),
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "int_param": {"type": "integer"},
                    "float_param": {"type": "float"},
                    "bool_param": {"type": "boolean"}
                }
            })),
            strict: None,
        }];

        let input = r#"<tool_call>
<function=test_func>
<parameter=int_param>not_an_int</parameter>
<parameter=float_param>not_a_float</parameter>
<parameter=bool_param>not_a_bool</parameter>
</function>
</tool_call>"#;

        let (calls, _) =
            try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
        assert_eq!(calls.len(), 1);

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();

        // All should fall back to strings
        assert_eq!(args["int_param"], "not_an_int");
        assert_eq!(args["float_param"], "not_a_float");
        // bool_param with invalid value defaults to false
        assert_eq!(args["bool_param"], false);
    }

    #[test] // helper
    fn test_anyof_param_parsed_as_object_not_string() {
        // When a tool parameter uses "anyOf" instead of a direct "type", the value
        // should be JSON-parsed (treated as object), not double-encoded as a string.
        // Regression test for: https://github.com/vllm-project/vllm/pull/36032
        let tools = vec![ToolDefinition {
            name: "get_weather".to_string(),
            parameters: Some(serde_json::json!({
                "type": "object",
                "required": ["location"],
                "properties": {
                    "location": {
                        "anyOf": [
                            {
                                "type": "object",
                                "properties": {"city": {"type": "string"}},
                                "required": ["city"]
                            },
                            {
                                "type": "object",
                                "properties": {
                                    "lat": {"type": "number"},
                                    "lon": {"type": "number"}
                                },
                                "required": ["lat", "lon"]
                            }
                        ]
                    }
                }
            })),
            strict: None,
        }];

        let input = r#"<tool_call>
<function=get_weather>
<parameter=location>
{"city": "Paris"}
</parameter>
</function>
</tool_call>"#;

        let (calls, _) =
            try_tool_call_parse_xml(input, &XmlParserConfig::default(), Some(&tools)).unwrap();
        assert_eq!(calls.len(), 1);

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        // Must be a proper object, not a double-encoded string like "{\"city\": \"Paris\"}"
        assert!(
            args["location"].is_object(),
            "Expected location to be an object, got: {}",
            args["location"]
        );
        assert_eq!(args["location"]["city"], "Paris");
    }

    #[test] // helper
    fn test_no_schema_fallback_behavior() {
        // Without schema, behavior should match old safe_parse_value logic
        let input = r#"<tool_call>
<function=unknown_func>
<parameter=param1>42</parameter>
<parameter=param2>true</parameter>
<parameter=param3>hello</parameter>
</function>
</tool_call>"#;

        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);

        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();

        // Without schema, all values are returned as strings (no type inference)
        assert_eq!(args["param1"], "42");
        assert_eq!(args["param2"], "true");
        assert_eq!(args["param3"], "hello");
    }

    /// Helper for the new corner-case tests below (TOOLCALLING.batch.6 / PIPELINE.finish_reason / TOOLCALLING.batch.9
    /// / TOOLCALLING.batch.10) — matches the production `ToolCallConfig::minimax_m2()`
    /// factory: strict inner blocks plus bare-invoke recovery.
    fn minimax_m2_config() -> XmlParserConfig {
        XmlParserConfig {
            tool_call_start_token: "<minimax:tool_call>".to_string(),
            tool_call_end_token: "</minimax:tool_call>".to_string(),
            function_start_token: "<invoke name=".to_string(),
            function_end_token: "</invoke>".to_string(),
            parameter_start_token: "<parameter name=".to_string(),
            parameter_end_token: "</parameter>".to_string(),
            allow_eof_recovery: false,
            strict_match: true,
            passthrough_when_no_function: false,
            backoff_when_no_wrapper: true,
        }
    }

    /// TOOLCALLING.batch.6 — empty args. A no-arg call (no `<parameter=...>` block)
    /// must still surface the function name with empty arguments.
    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.6.a in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.6.yaml.
    #[test] // TOOLCALLING.batch.6 — qwen3_coder
    fn test_parse_qwen3_empty_args() {
        let input = r#"<tool_call>
<function=current_time>
</function>
</tool_call>"#;
        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "current_time");
        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert_eq!(args, serde_json::json!({}));
    }

    /// TOOLCALLING.batch.6 — empty args, minimax_m2 format.
    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.6.a in tests/parity/toolcalling/fixtures/minimax_m2/TOOLCALLING.batch.6.yaml.
    #[test] // TOOLCALLING.batch.6 — minimax_m2
    fn test_parse_minimax_m2_empty_args() {
        let config = minimax_m2_config();
        let input =
            r#"<minimax:tool_call><invoke name="current_time"></invoke></minimax:tool_call>"#;
        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].function.name, "current_time");
        let args: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        assert_eq!(args, serde_json::json!({}));
    }

    /// Parser-level invariant: the xml parser is byte-stable — it doesn't
    /// see `finish_reason` and produces the same output regardless of the
    /// upstream stream-end reason. Real PIPELINE.finish_reason coverage (stop / tool_calls
    /// / length mapping) lives in `lib/llm/tests/test_streaming_tool_parsers.rs`
    /// and belongs in the cross-parser finish_reason mapping work-item
    /// (tracked separately).
    #[test]
    fn test_xml_qwen3_parser_output_independent_of_upstream_finish() {
        let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
NYC
</parameter>
</function>
</tool_call>"#;
        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 1);
    }

    /// Parser-level invariant — minimax_m2 variant. See qwen3 counterpart
    /// for the rationale.
    #[test]
    fn test_xml_minimax_m2_parser_output_independent_of_upstream_finish() {
        let config = minimax_m2_config();
        let input = r#"<minimax:tool_call><invoke name="get_weather"><parameter name="city">NYC</parameter></invoke></minimax:tool_call>"#;
        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert_eq!(calls.len(), 1);
    }

    /// TOOLCALLING.batch.9 — empty / null content variants. Truly-empty (zero bytes)
    /// and whitespace-only inputs must yield no tool calls; normal_text
    /// collapses to the empty string. Tested under both qwen3_coder and
    /// minimax_m2 configs.
    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.9 in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.yaml.
    #[test] // TOOLCALLING.batch.9 — qwen3_coder
    fn test_parse_qwen3_empty_and_whitespace_inputs() {
        for input in &["", " ", "\n", "\t\n  \t"] {
            let (calls, normal) =
                try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
            assert!(
                calls.is_empty(),
                "Empty/whitespace input must yield no calls (input={:?})",
                input
            );
            assert_eq!(
                normal.as_deref(),
                Some(""),
                "Empty/whitespace input collapses to empty normal_text (input={:?})",
                input
            );
        }
    }

    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.9 in tests/parity/toolcalling/fixtures/minimax_m2/TOOLCALLING.batch.yaml.
    #[test] // TOOLCALLING.batch.9 — minimax_m2
    fn test_parse_minimax_m2_empty_and_whitespace_inputs() {
        let config = minimax_m2_config();
        for input in &["", " ", "\n", "\t\n  \t"] {
            let (calls, normal) = try_tool_call_parse_xml(input, &config, None).unwrap();
            assert!(
                calls.is_empty(),
                "Empty/whitespace input must yield no calls (input={:?})",
                input
            );
            assert_eq!(
                normal.as_deref(),
                Some(""),
                "Empty/whitespace input collapses to empty normal_text (input={:?})",
                input
            );
        }
    }

    /// TOOLCALLING.batch.10 — duplicate calls (same function name twice). qwen3_coder
    /// format; pin parser-level behavior — both calls must come back with
    /// distinct ids.
    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.10 in tests/parity/toolcalling/fixtures/qwen3_coder/TOOLCALLING.batch.yaml.
    #[test] // TOOLCALLING.batch.10 — qwen3_coder
    fn test_parse_qwen3_duplicate_calls_same_name() {
        let input = r#"<tool_call>
<function=get_weather>
<parameter=city>
NYC
</parameter>
</function>
<function=get_weather>
<parameter=city>
LA
</parameter>
</function>
</tool_call>"#;
        let (calls, _) = try_tool_call_parse_xml(input, &XmlParserConfig::default(), None).unwrap();
        assert_eq!(calls.len(), 2, "Both duplicate-name calls must be returned");
        assert_eq!(calls[0].function.name, "get_weather");
        assert_eq!(calls[1].function.name, "get_weather");
        assert_ne!(
            calls[0].id, calls[1].id,
            "Duplicate calls must have distinct ids"
        );
        let args0: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        let args1: serde_json::Value = serde_json::from_str(&calls[1].function.arguments).unwrap();
        assert_eq!(args0["city"], "NYC");
        assert_eq!(args1["city"], "LA");
    }

    /// TOOLCALLING.batch.10 — duplicate calls (same function name twice). minimax_m2
    /// format; pin parser-level behavior — both calls must come back with
    /// distinct ids.
    // DEPRECATED(parser-fixture-duplicate): Duplicate of YAML fixture coverage: TOOLCALLING.batch.10 in tests/parity/toolcalling/fixtures/minimax_m2/TOOLCALLING.batch.yaml.
    #[test] // TOOLCALLING.batch.10 — minimax_m2
    fn test_parse_minimax_m2_duplicate_calls_same_name() {
        let config = minimax_m2_config();
        let input = r#"<minimax:tool_call><invoke name="get_weather"><parameter name="city">NYC</parameter></invoke><invoke name="get_weather"><parameter name="city">LA</parameter></invoke></minimax:tool_call>"#;
        let (calls, _) = try_tool_call_parse_xml(input, &config, None).unwrap();
        assert_eq!(calls.len(), 2, "Both duplicate-name calls must be returned");
        assert_eq!(calls[0].function.name, "get_weather");
        assert_eq!(calls[1].function.name, "get_weather");
        assert_ne!(
            calls[0].id, calls[1].id,
            "Duplicate calls must have distinct ids"
        );
        let args0: serde_json::Value = serde_json::from_str(&calls[0].function.arguments).unwrap();
        let args1: serde_json::Value = serde_json::from_str(&calls[1].function.arguments).unwrap();
        assert_eq!(args0["city"], "NYC");
        assert_eq!(args1["city"], "LA");
    }
}
