//! A deterministic tool-selection oracle: pick the tool whose name and
//! description best lexically match an intent.
//!
//! Proving that description quality changes selection needs a selector that
//! reacts to the description text without a live model. This module scores
//! each tool by how many of the intent's content words appear in the tool's
//! name plus description, and picks the best match. It is a deliberate proxy
//! for a model's tool choice: when a mutation strips the keywords that made a
//! description match an intent, the score drops and the pick can flip to a
//! competitor, which is exactly the selection impact the benchmark measures.
//!
//! It is not a claim about any specific model. It is a deterministic,
//! reproducible lower bound that runs in CI with no API key.

use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// One intent: a natural-language request plus the tool names that count as a
/// correct selection for it (an equal-function set, in benchmark terms).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Intent {
    /// The user request the selector reads.
    pub query: String,
    /// Tool names any one of which is a correct pick.
    pub gold: Vec<String>,
}

/// Content words to ignore so a match reflects topical overlap, not glue.
const STOPWORDS: &[&str] = &[
    "the", "a", "an", "of", "to", "for", "and", "or", "in", "on", "with", "my", "i", "me",
    "please", "that", "this", "is", "it", "do", "can", "you", "get", "give", "want", "need",
    "from", "by", "as", "at", "be",
];

/// Split text into lowercased content tokens of length >= 2, dropping
/// stopwords. Deterministic and allocation-light.
fn tokenize(text: &str) -> BTreeSet<String> {
    text.split(|c: char| !c.is_ascii_alphanumeric())
        .filter(|w| w.len() >= 2)
        .map(str::to_ascii_lowercase)
        .filter(|w| !STOPWORDS.contains(&w.as_str()))
        .collect()
}

/// The tokens a tool exposes to the selector: its name plus description.
fn tool_tokens(tool: &Value) -> BTreeSet<String> {
    let name = tool.get("name").and_then(Value::as_str).unwrap_or("");
    let desc = tool
        .get("description")
        .and_then(Value::as_str)
        .unwrap_or("");
    let mut tokens = tokenize(name);
    tokens.extend(tokenize(desc));
    tokens
}

/// Predict the tool a lexical selector would choose for `query`.
///
/// Scores each tool by the number of shared content words with the query,
/// breaking ties by the higher Jaccard overlap and then by catalog order so
/// the result is fully deterministic. Returns `None` when no tool shares a
/// single content word (the selector abstains rather than guess), which the
/// benchmark scores as a miss.
pub fn predict(query: &str, catalog: &Value) -> Option<String> {
    let intent_tokens = tokenize(query);
    if intent_tokens.is_empty() {
        return None;
    }
    let tools = catalog.get("tools").and_then(Value::as_array)?;
    let mut best: Option<(usize, f64, String)> = None;
    for tool in tools {
        let Some(name) = tool.get("name").and_then(Value::as_str) else {
            continue;
        };
        let tokens = tool_tokens(tool);
        let overlap = intent_tokens.intersection(&tokens).count();
        if overlap == 0 {
            continue;
        }
        let union = intent_tokens.union(&tokens).count().max(1);
        let jaccard = overlap as f64 / union as f64;
        let better = match &best {
            None => true,
            Some((bo, bj, _)) => overlap > *bo || (overlap == *bo && jaccard > *bj),
        };
        if better {
            best = Some((overlap, jaccard, name.to_string()));
        }
    }
    best.map(|(_, _, name)| name)
}

/// Run the selector over every intent and count correct picks.
///
/// A pick is correct when the predicted tool is in the intent's `gold` set.
/// Returns `(correct, total)` for a [`super::stats::wilson`] interval.
pub fn score(intents: &[Intent], catalog: &Value) -> (u64, u64) {
    let mut correct = 0u64;
    for intent in intents {
        if let Some(pick) = predict(&intent.query, catalog) {
            if intent.gold.iter().any(|g| g == &pick) {
                correct += 1;
            }
        }
    }
    (correct, intents.len() as u64)
}

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

    fn catalog() -> Value {
        json!({
            "tools": [
                {
                    "name": "get_weather",
                    "description": "Return the current weather forecast and temperature for a city."
                },
                {
                    "name": "convert_currency",
                    "description": "Convert an amount of money between two currencies at the latest exchange rate."
                }
            ]
        })
    }

    #[test]
    fn picks_the_topically_matching_tool() {
        assert_eq!(
            predict("what is the weather forecast in Paris", &catalog()).as_deref(),
            Some("get_weather")
        );
        assert_eq!(
            predict("convert 10 dollars of money to euros", &catalog()).as_deref(),
            Some("convert_currency")
        );
    }

    #[test]
    fn abstains_when_nothing_matches() {
        assert_eq!(predict("schedule a haircut appointment", &catalog()), None);
    }

    #[test]
    fn scores_correct_and_total_over_intents() {
        let intents = vec![
            Intent {
                query: "weather forecast for tomorrow".into(),
                gold: vec!["get_weather".into()],
            },
            Intent {
                query: "convert currency money exchange".into(),
                gold: vec!["convert_currency".into()],
            },
        ];
        assert_eq!(score(&intents, &catalog()), (2, 2));
    }
}
