//! Server-coherence signals for [`super::TdqsReport`].
//!
//! These three signals grade the catalog as a whole rather than tool by tool.
//! The disambiguation signal restates the SEC-011 tool-name-squat near-duplicate
//! rule from `crate::security::namespace` (normalize separators and case, then
//! Levenshtein distance 1), here over the single-server catalog. That function
//! is private to its module, so the same conservative rule lives here rather than
//! being imported; the two must stay in step.

use serde_json::Value;

use super::{CoherenceSignals, MIN_TOOLS_FOR_NAMING, TOOL_COUNT_SOFT_CAP};

/// Compute the three deterministic coherence signals over a catalog's tools.
pub fn coherence_signals(tools: &[Value]) -> CoherenceSignals {
    let names: Vec<String> = tools.iter().filter_map(tool_name).collect();
    CoherenceSignals {
        naming_consistency: naming_consistency(&names),
        tool_count: tool_count_score(tools.len()),
        disambiguation: disambiguation_score(tools, &names),
    }
}

fn tool_name(tool: &Value) -> Option<String> {
    tool.get("name")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
}

/// A naming style a tool name can follow. `Other` covers names that fit none of
/// the three common conventions (a single lowercase word, or a mixed form).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NameStyle {
    Snake,
    Camel,
    Kebab,
    Other,
}

/// Classify a tool name into one naming style. A name with no separator and no
/// internal capital (a single lowercase word like `search`) is `Other` because
/// it commits to no convention and so neither sets nor breaks one.
fn classify(name: &str) -> NameStyle {
    if name.contains('_') {
        return NameStyle::Snake;
    }
    if name.contains('-') {
        return NameStyle::Kebab;
    }
    if name.chars().any(|c| c.is_uppercase()) {
        return NameStyle::Camel;
    }
    NameStyle::Other
}

/// Fraction of named tools that follow the catalog's dominant style.
///
/// Full score below [`MIN_TOOLS_FOR_NAMING`] (too few tools to share a
/// convention) or when no tool commits to a recognizable style. Otherwise it is
/// the share of tools matching the most common non-`Other` style, so a catalog
/// that mixes `get_user` with `getUser` scores below one.
fn naming_consistency(names: &[String]) -> f64 {
    if names.len() < MIN_TOOLS_FOR_NAMING {
        return 1.0;
    }
    let styles: Vec<NameStyle> = names.iter().map(|n| classify(n)).collect();
    let committed: Vec<NameStyle> = styles
        .iter()
        .copied()
        .filter(|s| *s != NameStyle::Other)
        .collect();
    if committed.is_empty() {
        return 1.0;
    }
    let dominant = dominant_style(&committed);
    let matching = styles.iter().filter(|s| **s == dominant).count();
    matching as f64 / names.len() as f64
}

/// The most frequent style among the committed names. Ties resolve to the first
/// style encountered, which is enough for a deterministic dominant pick.
fn dominant_style(committed: &[NameStyle]) -> NameStyle {
    let mut best = committed[0];
    let mut best_count = 0usize;
    for candidate in [NameStyle::Snake, NameStyle::Camel, NameStyle::Kebab] {
        let count = committed.iter().filter(|s| **s == candidate).count();
        if count > best_count {
            best_count = count;
            best = candidate;
        }
    }
    best
}

/// Tool-count appropriateness, 0.0 to 1.0.
///
/// Full at or below [`TOOL_COUNT_SOFT_CAP`], then decaying linearly to 0.0 at
/// twice the cap so a sprawling flat catalog scores lower. This is the
/// token-budget half of the coherence story: tool-selection accuracy drops as
/// the catalog grows (the reliability tradeoff  measures).
fn tool_count_score(count: usize) -> f64 {
    if count <= TOOL_COUNT_SOFT_CAP {
        return 1.0;
    }
    let over = (count - TOOL_COUNT_SOFT_CAP) as f64;
    (1.0 - over / TOOL_COUNT_SOFT_CAP as f64).max(0.0)
}

/// Disambiguation, 0.0 to 1.0.
///
/// Counts confusable pairs (near-duplicate names by the SEC-011 rule, or
/// identical descriptions across two tools) and scores one minus the fraction of
/// all pairs that are confusable. A catalog of clearly distinct tools scores 1.0;
/// each ambiguous pair pulls it down. A catalog with fewer than two tools has no
/// pairs to confuse and scores 1.0.
fn disambiguation_score(tools: &[Value], names: &[String]) -> f64 {
    if tools.len() < 2 {
        return 1.0;
    }
    let descriptions: Vec<String> = tools
        .iter()
        .map(|t| {
            t.get("description")
                .and_then(|d| d.as_str())
                .unwrap_or("")
                .trim()
                .to_lowercase()
        })
        .collect();
    let total_pairs = tools.len() * (tools.len() - 1) / 2;
    let mut confusable = 0usize;
    for i in 0..tools.len() {
        for j in (i + 1)..tools.len() {
            if names_confusable(names, i, j) || descriptions_confusable(&descriptions, i, j) {
                confusable += 1;
            }
        }
    }
    if total_pairs == 0 {
        return 1.0;
    }
    1.0 - (confusable as f64 / total_pairs as f64)
}

/// True when tools `i` and `j` both have names and those names are near
/// duplicates by the SEC-011 rule.
fn names_confusable(names: &[String], i: usize, j: usize) -> bool {
    match (names.get(i), names.get(j)) {
        (Some(a), Some(b)) => is_near_duplicate(a, b),
        _ => false,
    }
}

/// True when tools `i` and `j` carry the same non-empty description, so an agent
/// reading descriptions alone cannot tell them apart.
fn descriptions_confusable(descriptions: &[String], i: usize, j: usize) -> bool {
    match (descriptions.get(i), descriptions.get(j)) {
        (Some(a), Some(b)) => !a.is_empty() && a == b,
        _ => false,
    }
}

/// True when two names are confusable: identical after normalizing separators
/// and case, or within Levenshtein distance 1. Restates the SEC-011
/// `is_near_duplicate` rule (private to `crate::security::namespace`). Equal
/// inputs are not a squat, so they return false.
fn is_near_duplicate(a: &str, b: &str) -> bool {
    if a == b {
        return false;
    }
    if normalize(a) == normalize(b) {
        return true;
    }
    levenshtein_within_one(a, b)
}

/// Lowercase and strip `-`, `_`, `.`, and space separators, so `get_user`,
/// `get-user`, and `GetUser` collapse to one form. Matches the SEC-011
/// `normalize`.
fn normalize(name: &str) -> String {
    name.chars()
        .filter(|c| !matches!(c, '-' | '_' | '.' | ' '))
        .flat_map(|c| c.to_lowercase())
        .collect()
}

/// True when `a` and `b` are within Levenshtein distance 1. A length gap above 1
/// cannot be one edit, so it short-circuits.
fn levenshtein_within_one(a: &str, b: &str) -> bool {
    let ac: Vec<char> = a.chars().collect();
    let bc: Vec<char> = b.chars().collect();
    if ac.len().abs_diff(bc.len()) > 1 {
        return false;
    }
    levenshtein(&ac, &bc) <= 1
}

/// Rolling-row Levenshtein distance over char slices, O(len_a * len_b) time and
/// O(len_b) space. Matches the SEC-011 `levenshtein`.
fn levenshtein(a: &[char], b: &[char]) -> usize {
    let mut prev: Vec<usize> = (0..=b.len()).collect();
    let mut curr = vec![0usize; b.len() + 1];
    for (i, ca) in a.iter().enumerate() {
        curr[0] = i + 1;
        for (j, cb) in b.iter().enumerate() {
            let cost = if ca == cb { 0 } else { 1 };
            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
        }
        std::mem::swap(&mut prev, &mut curr);
    }
    prev[b.len()]
}

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

    #[test]
    fn naming_consistency_full_for_uniform_snake_case() {
        let names = vec![
            "get_user".to_string(),
            "list_users".to_string(),
            "delete_user".to_string(),
        ];
        assert_eq!(naming_consistency(&names), 1.0);
    }

    #[test]
    fn naming_consistency_drops_when_styles_are_mixed() {
        let names = vec![
            "get_user".to_string(),
            "listUsers".to_string(),
            "delete-user".to_string(),
        ];
        // One snake, one camel, one kebab: dominant style covers one of three.
        let score = naming_consistency(&names);
        assert!(score < 1.0);
        assert!((score - 1.0 / 3.0).abs() < 1e-9);
    }

    #[test]
    fn naming_consistency_exempts_tiny_catalogs() {
        let names = vec!["get_user".to_string(), "listUsers".to_string()];
        assert_eq!(naming_consistency(&names), 1.0);
    }

    #[test]
    fn tool_count_full_under_soft_cap_and_decays_above() {
        assert_eq!(tool_count_score(10), 1.0);
        assert_eq!(tool_count_score(TOOL_COUNT_SOFT_CAP), 1.0);
        assert!(tool_count_score(TOOL_COUNT_SOFT_CAP + 20) < 1.0);
        // At twice the cap the score floors at zero.
        assert_eq!(tool_count_score(TOOL_COUNT_SOFT_CAP * 2), 0.0);
    }

    #[test]
    fn disambiguation_full_for_distinct_tools() {
        let tools = vec![
            json!({ "name": "search", "description": "search the web" }),
            json!({ "name": "weather", "description": "fetch the forecast" }),
        ];
        let names: Vec<String> = vec!["search".into(), "weather".into()];
        assert_eq!(disambiguation_score(&tools, &names), 1.0);
    }

    #[test]
    fn disambiguation_penalizes_near_duplicate_names() {
        let tools = vec![
            json!({ "name": "delete_record", "description": "delete one record" }),
            json!({ "name": "delete_recrd", "description": "delete one record by id" }),
        ];
        let names: Vec<String> = vec!["delete_record".into(), "delete_recrd".into()];
        // The single pair is confusable, so the only pair scores zero.
        assert_eq!(disambiguation_score(&tools, &names), 0.0);
    }

    #[test]
    fn disambiguation_penalizes_identical_descriptions() {
        let tools = vec![
            json!({ "name": "alpha", "description": "does the thing" }),
            json!({ "name": "beta", "description": "does the thing" }),
        ];
        let names: Vec<String> = vec!["alpha".into(), "beta".into()];
        assert_eq!(disambiguation_score(&tools, &names), 0.0);
    }

    #[test]
    fn near_duplicate_matches_sec_011_separator_and_distance_rules() {
        assert!(is_near_duplicate("get_user", "GetUser"));
        assert!(is_near_duplicate("search", "serch"));
        assert!(!is_near_duplicate("search", "weather"));
        assert!(!is_near_duplicate("search", "search"));
    }

    #[test]
    fn coherence_signals_rolls_up_all_three() {
        let tools = vec![
            json!({ "name": "get_user", "description": "get a user" }),
            json!({ "name": "list_users", "description": "list users" }),
            json!({ "name": "delete_user", "description": "delete a user" }),
        ];
        let signals = coherence_signals(&tools);
        assert_eq!(signals.naming_consistency, 1.0);
        assert_eq!(signals.tool_count, 1.0);
        assert_eq!(signals.disambiguation, 1.0);
        assert_eq!(signals.score(), 1.0);
    }
}
