//! Semantic-type inference for the negative fuzzer (WOR-1470).
//!
//! Why this module exists: the structural fuzzer in [`crate::eval::fuzz`] sends
//! type-confused and boundary values, but a parameter named `email` or carrying
//! `"format": "email"` invites a sharper probe: a syntactically malformed value
//! of the right JSON type. A bare wrong-type case (a number where a string is
//! wanted) is caught by any schema validator; a malformed email string slips
//! past type checks and exercises the server's own validation. This module
//! infers a likely semantic type from the property name, description, and schema
//! `format`, and offers targeted invalid values per type.
//!
//! Inference is conservative and pure. Scores are additive and clamped to 1.0,
//! and a candidate must clear [`CONFIDENCE_FLOOR`] before the fuzzer emits its
//! cases, so an ambiguous parameter falls back to the generic negatives alone.
//! The format-hint mapping is kept aligned with
//! [`crate::stub_gen::synthesis`]'s `format_sample` so the positive synthesizer
//! and this negative prober agree on what a `format` means.

use serde_json::{json, Value};

/// The minimum confidence an [`Inference`] must reach before the fuzzer emits
/// its targeted invalid cases. Below this, only the generic negatives run.
pub const CONFIDENCE_FLOOR: f64 = 0.6;

/// A semantic type guessed for a tool parameter.
///
/// These are the value shapes worth a malformed-but-right-JSON-type probe:
/// each maps to a small set of invalid values in [`targeted_invalids`] that a
/// careless server might accept or choke on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SemanticType {
    /// An email address, for example `user@example.com`.
    Email,
    /// A URL or URI, for example `https://example.com`.
    Url,
    /// A UUID, for example `00000000-0000-4000-8000-000000000000`.
    Uuid,
    /// A calendar date, for example `2026-01-15`.
    Date,
    /// A date-time, for example `2026-01-15T10:30:00Z`.
    DateTime,
    /// A Unix epoch timestamp as a number.
    Timestamp,
    /// A monetary amount.
    Currency,
    /// A percentage, expected within a sensible range.
    Percentage,
    /// An IP address (v4 or v6) or hostname.
    IpAddress,
    /// A filesystem path.
    FilePath,
    /// A JSON document carried in a string.
    Json,
    /// Base64-encoded data carried in a string.
    Base64,
    /// A regular expression carried in a string.
    Regex,
}

/// A semantic-type guess and how confident the inference is.
///
/// The `confidence` is the additive score of the matching signals, clamped to
/// 1.0; [`is_confident`] compares it against [`CONFIDENCE_FLOOR`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Inference {
    /// The guessed semantic type.
    pub semantic_type: SemanticType,
    /// The additive confidence score in `[0.0, 1.0]`.
    pub confidence: f64,
}

/// True when an inference clears the confidence floor and its targeted invalids
/// are worth emitting.
///
/// Gating here keeps a weak guess (a description word alone) from polluting the
/// case set with probes that do not match the parameter's real shape.
pub fn is_confident(inf: &Inference) -> bool {
    inf.confidence >= CONFIDENCE_FLOOR
}

/// Infer the most likely semantic type for a parameter from its name,
/// description, and schema `format`, or `None` when nothing scores.
///
/// Signals are additive and clamped to 1.0: a `format` hint is near-certain, an
/// exact name token strong, a partial name token moderate, and a description
/// token weak. A numeric guard zeroes any candidate whose declared JSON type
/// conflicts with the type (a string-shaped type on an integer parameter, or a
/// numeric type on a string parameter), so `amount` on an integer scores as
/// Currency, never Email. The highest surviving candidate wins.
pub fn infer(name: &str, prop: &Value) -> Option<Inference> {
    let declared = prop.get("type").and_then(Value::as_str);
    let format = prop.get("format").and_then(Value::as_str);
    let description = prop
        .get("description")
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_lowercase();
    let lower_name = name.to_lowercase();

    let mut best: Option<Inference> = None;
    for &semantic_type in ALL_TYPES {
        let raw = score(semantic_type, &lower_name, &description, format);
        let confidence = (raw * numeric_guard(semantic_type, declared)).min(1.0);
        if confidence <= 0.0 {
            continue;
        }
        if best.is_none_or(|b| confidence > b.confidence) {
            best = Some(Inference {
                semantic_type,
                confidence,
            });
        }
    }
    best
}

/// Targeted malformed values for a semantic type, each with a stable label
/// suffix.
///
/// Every value keeps the parameter's right JSON type (a malformed string for a
/// string type, an out-of-range number for a numeric type) so the probe slips
/// past a plain type check and exercises the server's domain validation. The
/// label suffix names the defect for the fuzz report.
pub fn targeted_invalids(semantic_type: SemanticType) -> Vec<(&'static str, Value)> {
    match semantic_type {
        SemanticType::Email => vec![
            ("malformed_email", json!("not-an-email")),
            ("missing_at", json!("user.example.com")),
        ],
        SemanticType::Url => vec![
            ("malformed_url", json!("ht!tp://[invalid")),
            ("schemeless_url", json!("example.com/x")),
        ],
        SemanticType::Uuid => vec![
            ("malformed_uuid", json!("not-a-uuid")),
            ("short_uuid", json!("1234")),
        ],
        SemanticType::Date => vec![
            ("impossible_date", json!("2026-13-45")),
            ("wrong_date_format", json!("15/01/2026")),
        ],
        SemanticType::DateTime => vec![("impossible_datetime", json!("2026-01-15 25:99:99"))],
        SemanticType::Timestamp => vec![
            ("negative_timestamp", json!(-1)),
            ("overflow_timestamp", json!(i64::MAX)),
        ],
        SemanticType::Currency => vec![
            ("negative_amount", json!(-1)),
            ("non_numeric_amount", json!("free")),
        ],
        SemanticType::Percentage => vec![
            ("over_one_hundred_percent", json!(101)),
            ("negative_percent", json!(-1)),
        ],
        SemanticType::IpAddress => vec![("out_of_range_ip", json!("999.999.999.999"))],
        SemanticType::FilePath => vec![
            ("path_traversal", json!("../../etc/passwd")),
            ("null_byte_path", json!("a\u{0}b")),
        ],
        SemanticType::Json => vec![("malformed_json", json!("{not valid json"))],
        SemanticType::Base64 => vec![("malformed_base64", json!("!!!not base64!!!"))],
        SemanticType::Regex => vec![("unclosed_group", json!("([unclosed"))],
    }
}

// --- inference helpers ---

/// Every variant, in a stable order, so [`infer`] sweeps all candidates.
const ALL_TYPES: &[SemanticType] = &[
    SemanticType::Email,
    SemanticType::Url,
    SemanticType::Uuid,
    SemanticType::Date,
    SemanticType::DateTime,
    SemanticType::Timestamp,
    SemanticType::Currency,
    SemanticType::Percentage,
    SemanticType::IpAddress,
    SemanticType::FilePath,
    SemanticType::Json,
    SemanticType::Base64,
    SemanticType::Regex,
];

/// Additive score for one candidate type: format hint plus name and description
/// signals, before the numeric guard.
fn score(semantic_type: SemanticType, name: &str, description: &str, format: Option<&str>) -> f64 {
    let mut total = 0.0;
    if format_matches(semantic_type, format) {
        total += 0.95;
    }
    let tokens = name_tokens(semantic_type);
    if tokens.contains(&name) {
        total += 0.85;
    } else if tokens.iter().any(|t| name.contains(t)) {
        total += 0.55;
    }
    if tokens.iter().any(|t| description.contains(t)) {
        total += 0.45;
    }
    total
}

/// Whether a schema `format` maps to this semantic type, aligned with
/// [`crate::stub_gen::synthesis`]'s `format_sample`.
fn format_matches(semantic_type: SemanticType, format: Option<&str>) -> bool {
    let Some(format) = format else {
        return false;
    };
    let mapped = match format {
        "email" | "idn-email" => SemanticType::Email,
        "uri" | "url" | "uri-reference" => SemanticType::Url,
        "uuid" => SemanticType::Uuid,
        "date" => SemanticType::Date,
        "date-time" => SemanticType::DateTime,
        "ipv4" | "ipv6" | "hostname" | "idn-hostname" => SemanticType::IpAddress,
        _ => return false,
    };
    mapped == semantic_type
}

/// The name and description token family for a semantic type.
fn name_tokens(semantic_type: SemanticType) -> &'static [&'static str] {
    match semantic_type {
        SemanticType::Email => &["email"],
        SemanticType::Url => &["url"],
        SemanticType::Uuid => &["uuid"],
        SemanticType::Date => &["date"],
        SemanticType::DateTime => &["datetime"],
        SemanticType::Timestamp => &["timestamp"],
        SemanticType::Currency => &[
            "price", "amount", "cost", "fee", "total", "balance", "payment",
        ],
        SemanticType::Percentage => &["percent", "pct", "ratio"],
        SemanticType::IpAddress => &["ip"],
        SemanticType::FilePath => &["path", "file", "dir", "folder"],
        SemanticType::Json => &["json"],
        SemanticType::Base64 => &["base64"],
        SemanticType::Regex => &["regex", "pattern"],
    }
}

/// Multiplier that zeroes a candidate whose declared JSON type conflicts with
/// the semantic type, and passes it through otherwise.
///
/// Numeric semantic types (Currency, Percentage, Timestamp) make sense only for
/// `integer`/`number` parameters; the string-shaped types make sense only for
/// `string`. An absent declared type matches either, so a schema-less parameter
/// keeps its name-based guess.
fn numeric_guard(semantic_type: SemanticType, declared: Option<&str>) -> f64 {
    let Some(declared) = declared else {
        return 1.0;
    };
    let numeric = matches!(
        semantic_type,
        SemanticType::Currency | SemanticType::Percentage | SemanticType::Timestamp
    );
    let ok = if numeric {
        matches!(declared, "integer" | "number")
    } else {
        declared == "string"
    };
    if ok {
        1.0
    } else {
        0.0
    }
}

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

    #[test]
    fn infers_email_from_param_name() {
        let inf = infer("email", &json!({ "type": "string" })).expect("email inferred");
        assert_eq!(inf.semantic_type, SemanticType::Email);
        assert!(is_confident(&inf), "exact name token clears the floor");
    }

    #[test]
    fn infers_email_from_description() {
        // The name carries no token family, so the inference rides on the
        // description token alone (a weak signal below the confidence floor).
        let prop = json!({ "type": "string", "description": "The user's email to notify" });
        let inf = infer("contact_to_notify", &prop).expect("email inferred from description");
        assert_eq!(inf.semantic_type, SemanticType::Email);
        assert!(
            !is_confident(&inf),
            "a description token alone stays below the floor"
        );
    }

    #[test]
    fn infers_from_schema_format_hint_with_high_confidence() {
        let inf = infer("contact", &json!({ "type": "string", "format": "email" }))
            .expect("format hint infers email");
        assert_eq!(inf.semantic_type, SemanticType::Email);
        assert!(inf.confidence >= 0.95, "format hint is near-certain");
    }

    #[test]
    fn ambiguous_param_falls_below_the_confidence_floor() {
        // "note" carries no token family and no format; nothing scores.
        let inf = infer("note", &json!({ "type": "string" }));
        assert!(inf.is_none(), "an ambiguous param yields no inference");
    }

    #[test]
    fn numeric_guard_rejects_string_only_types_on_numeric_params() {
        // `amount` could read as a Currency token, but on a string param the
        // numeric guard would zero Currency; on an integer it must be Currency,
        // never Email, and the string-only types are excluded.
        let inf = infer("amount", &json!({ "type": "integer" })).expect("currency inferred");
        assert_eq!(inf.semantic_type, SemanticType::Currency);
    }

    #[test]
    fn email_invalids_keep_the_string_type_but_break_the_format() {
        let invalids = targeted_invalids(SemanticType::Email);
        assert!(!invalids.is_empty());
        for (_, value) in &invalids {
            assert!(value.is_string(), "an email invalid stays a string");
            assert!(
                !value.as_str().unwrap().contains('@') || !value.as_str().unwrap().contains('.'),
                "an email invalid is malformed"
            );
        }
    }

    #[test]
    fn percentage_invalids_are_out_of_range_numbers() {
        let invalids = targeted_invalids(SemanticType::Percentage);
        assert!(invalids.iter().any(|(_, v)| v.as_i64() == Some(101)));
        assert!(invalids.iter().any(|(_, v)| v.as_i64() == Some(-1)));
    }

    #[test]
    fn uuid_invalids_are_malformed_strings() {
        let invalids = targeted_invalids(SemanticType::Uuid);
        assert!(invalids.iter().all(|(_, v)| v.is_string()));
        assert!(invalids
            .iter()
            .any(|(_, v)| v.as_str() == Some("not-a-uuid")));
    }
}
