//! Lightweight Fellegi-Sunter scoring for real-time resolve.
//!
//! This module provides a standalone scoring function that can score
//! a single incoming record against a candidate entity using pre-trained
//! FS parameters. No `ReconciliationEngine` setup needed.

use std::collections::HashMap;
use cannon_common::ir::{CompiledFellegiSunterPlan, CompiledFsField, FsComparatorType};
use crate::engine::normalization::{
    Normalizer, NormalizationResult,
    EmailNormalizer, PhoneNormalizer, NameNormalizer,
    NicknameNormalizer, DomainNormalizer, GenericNormalizer,
};

/// Result of scoring one candidate against an incoming record.
#[derive(Debug, Clone)]
pub struct RealtimeScore {
    /// Raw composite log-likelihood score
    pub composite: f64,
    /// Per-field contributions for debugging
    pub field_scores: Vec<(String, f64, f64)>, // (field_name, agreement, contribution)
}

/// Normalize a value using the named normalizer.
/// Returns the normalized string, or the original if normalization fails.
pub fn normalize_value(value: &str, normalizer_name: &str) -> String {
    let result = match normalizer_name {
        "email" => EmailNormalizer.normalize(value),
        "phone" => PhoneNormalizer.normalize(value),
        "name" => NameNormalizer.normalize(value),
        "nickname" => NicknameNormalizer.normalize(value),
        "domain" => DomainNormalizer.normalize(value),
        _ => GenericNormalizer.normalize(value),
    };
    match result {
        NormalizationResult::Normalized(s) => s,
        NormalizationResult::Invalid(_) => value.to_lowercase().trim().to_string(),
    }
}

/// Normalize all fields in a data map using the FS plan's normalizer config.
pub fn normalize_fields(
    data: &HashMap<String, String>,
    fs_fields: &[CompiledFsField],
) -> HashMap<String, String> {
    let mut normalized = HashMap::new();
    for (key, value) in data {
        if value.is_empty() {
            continue;
        }
        // Find the FS field config to get the normalizer name
        let normalizer_name = fs_fields.iter()
            .find(|f| f.name == *key)
            .and_then(|f| f.normalizer.as_deref());

        let norm_value = if let Some(name) = normalizer_name {
            normalize_value(value, name)
        } else {
            value.to_lowercase().trim().to_string()
        };
        normalized.insert(key.clone(), norm_value);
    }
    normalized
}

/// Compute agreement score between two field values using the specified comparator.
/// Returns a score in [0.0, 1.0], or NaN if either value is missing/empty.
pub fn compute_field_agreement(
    a_value: Option<&str>,
    b_value: Option<&str>,
    comparator: &FsComparatorType,
) -> f64 {
    let (a, b) = match (a_value, b_value) {
        (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() => (a, b),
        _ => return f64::NAN,
    };

    if a == b {
        return 1.0;
    }

    match comparator {
        FsComparatorType::Exact => 0.0,
        FsComparatorType::JaroWinkler => strsim::jaro_winkler(a, b),
        FsComparatorType::Levenshtein => strsim::normalized_levenshtein(a, b),
        FsComparatorType::Soundex => {
            use rphonetic::{Encoder, Soundex};
            let encoder = Soundex::default();
            if encoder.encode(a) == encoder.encode(b) { 1.0 } else { 0.0 }
        }
        FsComparatorType::Metaphone => {
            use rphonetic::{Encoder, DoubleMetaphone};
            let encoder = DoubleMetaphone::default();
            if encoder.encode(a) == encoder.encode(b) { 1.0 }
            else if encoder.encode(a) == encoder.encode_alternate(b) { 0.5 }
            else { 0.0 }
        }
        FsComparatorType::Cosine => {
            // Bigram-based cosine similarity
            fn bigrams(s: &str) -> HashMap<String, f64> {
                let chars: Vec<char> = s.chars().collect();
                let mut map = HashMap::new();
                for w in chars.windows(2) {
                    let bg: String = w.iter().collect();
                    *map.entry(bg).or_insert(0.0) += 1.0;
                }
                map
            }
            let bg_a = bigrams(a);
            let bg_b = bigrams(b);
            let dot: f64 = bg_a.iter()
                .filter_map(|(k, v)| bg_b.get(k).map(|vb| v * vb))
                .sum();
            let mag_a: f64 = bg_a.values().map(|v| v * v).sum::<f64>().sqrt();
            let mag_b: f64 = bg_b.values().map(|v| v * v).sum::<f64>().sqrt();
            if mag_a > 0.0 && mag_b > 0.0 { dot / (mag_a * mag_b) } else { 0.0 }
        }
    }
}

/// Score an incoming record against a candidate entity using a compiled FS plan.
///
/// Both `incoming` and `candidate` should contain normalized field values.
/// Returns the raw composite log-likelihood score.
pub fn score_pair(
    incoming: &HashMap<String, String>,
    candidate: &HashMap<String, String>,
    fs_plan: &CompiledFellegiSunterPlan,
) -> RealtimeScore {
    let mut composite = 0.0_f64;
    let mut field_scores = Vec::new();

    for field in &fs_plan.fields {
        let a_val = incoming.get(&field.name).map(|s| s.as_str());
        let b_val = candidate.get(&field.name).map(|s| s.as_str());

        let agreement = compute_field_agreement(a_val, b_val, &field.comparator);

        if agreement.is_nan() {
            // Missing data = no evidence, contribute 0.0
            field_scores.push((field.name.clone(), f64::NAN, 0.0));
            continue;
        }

        // Interpolate between disagree and agree weights based on similarity
        let contribution = field.w_disagree + agreement * (field.w_agree - field.w_disagree);
        composite += contribution;
        field_scores.push((field.name.clone(), agreement, contribution));
    }

    RealtimeScore { composite, field_scores }
}

/// Generate blocking keys for a record based on its normalized fields.
/// Returns a list of (block_type, block_value) pairs for SQL lookup.
pub fn generate_blocking_keys(data: &HashMap<String, String>) -> Vec<(String, String)> {
    let mut keys = Vec::new();

    if let Some(email) = data.get("email") {
        if !email.is_empty() {
            keys.push(("email".to_string(), email.clone()));
        }
    }

    if let Some(phone) = data.get("phone") {
        if !phone.is_empty() {
            // Use raw digits for blocking -- no country-code assumptions.
            // The SQL side does the same regexp_replace to strip non-digits.
            // We generate BOTH the raw-digit form and the original value
            // so we match regardless of whether canonical_data is E.164 or raw.
            let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
            if digits.len() >= 7 {
                keys.push(("phone".to_string(), digits));
            }
        }
    }

    // Name blocking: last_name + first_name
    if let (Some(last), Some(first)) = (data.get("last_name"), data.get("first_name")) {
        if !last.is_empty() && !first.is_empty() {
            keys.push(("name".to_string(), format!("{}:{}", last, first)));
        }
    }

    // Company + last_name blocking
    if let (Some(company), Some(last)) = (data.get("company_name"), data.get("last_name")) {
        if !company.is_empty() && !last.is_empty() {
            keys.push(("company_name".to_string(), format!("{}:{}", company, last)));
        }
    }

    keys
}

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

    fn make_fs_plan() -> CompiledFellegiSunterPlan {
        CompiledFellegiSunterPlan {
            fields: vec![
                CompiledFsField {
                    name: "email".to_string(),
                    comparator: FsComparatorType::Exact,
                    weight: 2.0,
                    m_probability: 0.95,
                    u_probability: 0.001,
                    w_agree: (0.95_f64 / 0.001).ln() * 2.0,
                    w_disagree: ((1.0_f64 - 0.95) / (1.0 - 0.001)).ln() * 2.0,
                    normalizer: Some("email".to_string()),
                },
                CompiledFsField {
                    name: "first_name".to_string(),
                    comparator: FsComparatorType::JaroWinkler,
                    weight: 1.0,
                    m_probability: 0.85,
                    u_probability: 0.05,
                    w_agree: (0.85_f64 / 0.05).ln(),
                    w_disagree: ((1.0_f64 - 0.85) / (1.0 - 0.05)).ln(),
                    normalizer: Some("nickname".to_string()),
                },
                CompiledFsField {
                    name: "last_name".to_string(),
                    comparator: FsComparatorType::JaroWinkler,
                    weight: 1.0,
                    m_probability: 0.88,
                    u_probability: 0.02,
                    w_agree: (0.88_f64 / 0.02).ln(),
                    w_disagree: ((1.0_f64 - 0.88) / (1.0 - 0.02)).ln(),
                    normalizer: Some("name".to_string()),
                },
            ],
            match_threshold: 8.0,
            possible_threshold: 4.0,
            non_match_threshold: -5.0,
            merge_threshold: Some(12.0),
            max_composite: 0.0, // not needed for scoring
            min_composite: 0.0,
            em_training: None,
        }
    }

    #[test]
    fn test_exact_email_match_scores_high() {
        let plan = make_fs_plan();
        let incoming: HashMap<String, String> = [
            ("email".into(), "bob@gmail.com".into()),
            ("first_name".into(), "robert".into()),
            ("last_name".into(), "smith".into()),
        ].into();
        let candidate: HashMap<String, String> = [
            ("email".into(), "bob@gmail.com".into()),
            ("first_name".into(), "robert".into()),
            ("last_name".into(), "smith".into()),
        ].into();

        let result = score_pair(&incoming, &candidate, &plan);
        assert!(result.composite >= plan.match_threshold,
            "Exact match should score above match threshold, got {}", result.composite);
    }

    #[test]
    fn test_no_match_scores_low() {
        let plan = make_fs_plan();
        let incoming: HashMap<String, String> = [
            ("email".into(), "alice@example.com".into()),
            ("first_name".into(), "alice".into()),
            ("last_name".into(), "jones".into()),
        ].into();
        let candidate: HashMap<String, String> = [
            ("email".into(), "bob@gmail.com".into()),
            ("first_name".into(), "robert".into()),
            ("last_name".into(), "smith".into()),
        ].into();

        let result = score_pair(&incoming, &candidate, &plan);
        assert!(result.composite < plan.possible_threshold,
            "Non-match should score below possible threshold, got {}", result.composite);
    }

    #[test]
    fn test_name_similarity_scores_moderate() {
        let plan = make_fs_plan();
        let incoming: HashMap<String, String> = [
            ("email".into(), "different@example.com".into()),
            ("first_name".into(), "robert".into()),
            ("last_name".into(), "smith".into()),
        ].into();
        let candidate: HashMap<String, String> = [
            ("email".into(), "bob@gmail.com".into()),
            ("first_name".into(), "rob".into()),
            ("last_name".into(), "smith".into()),
        ].into();

        let result = score_pair(&incoming, &candidate, &plan);
        // Different email penalizes heavily, but name match helps
        // Score should be somewhere in the middle
        assert!(result.composite < plan.match_threshold,
            "Partial match should not be above match threshold");
    }

    #[test]
    fn test_missing_field_contributes_zero() {
        let plan = make_fs_plan();
        let incoming: HashMap<String, String> = [
            ("email".into(), "bob@gmail.com".into()),
            ("first_name".into(), "bob".into()),
        ].into();
        let candidate: HashMap<String, String> = [
            ("email".into(), "bob@gmail.com".into()),
            ("first_name".into(), "bob".into()),
            ("last_name".into(), "smith".into()),
        ].into();

        let result = score_pair(&incoming, &candidate, &plan);
        // last_name missing from incoming, should contribute 0 (NaN -> skip)
        let last_name_score = result.field_scores.iter()
            .find(|(name, _, _)| name == "last_name")
            .map(|(_, agreement, contribution)| (*agreement, *contribution));
        assert!(last_name_score.unwrap().0.is_nan());
        assert_eq!(last_name_score.unwrap().1, 0.0);
    }

    #[test]
    fn test_normalize_value_email() {
        assert_eq!(normalize_value("BOB@Gmail.com", "email"), "bob@gmail.com");
    }

    #[test]
    fn test_normalize_value_nickname() {
        let result = normalize_value("Bob", "nickname");
        assert_eq!(result, "robert");
    }

    #[test]
    fn test_generate_blocking_keys() {
        let data: HashMap<String, String> = [
            ("email".into(), "bob@gmail.com".into()),
            ("phone".into(), "5551234567".into()),
            ("first_name".into(), "bob".into()),
            ("last_name".into(), "smith".into()),
            ("company_name".into(), "acme".into()),
        ].into();

        let keys = generate_blocking_keys(&data);
        assert!(keys.iter().any(|(t, _)| t == "email"));
        assert!(keys.iter().any(|(t, _)| t == "phone"));
        assert!(keys.iter().any(|(t, _)| t == "name"));
        assert!(keys.iter().any(|(t, _)| t == "company_name"));
    }
}
