//! 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,
    BarcodeNormalizer, MpnNormalizer, BrandNormalizer,
};
use crate::engine::geospatial::{encode_geohash, embedding_cosine_similarity, speed_constraint_score};

/// 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)
}

/// Extended score result with optional semantic resolution data.
#[derive(Debug, Clone)]
pub struct RealtimeScoreWithSemantics {
    /// Base score result
    pub score: RealtimeScore,
    /// Inferred subtype for the incoming record (if semantic engine active)
    pub subtype_incoming: Option<super::types::SubtypeInference>,
    /// Inferred subtype for the candidate record (if semantic engine active)
    pub subtype_candidate: Option<super::types::SubtypeInference>,
    /// Final resolution decision after semantic constraints
    pub resolution: crate::engine::types::ResolutionDecision,
}

/// 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),
        "barcode" | "gtin" | "upc" | "ean" => BarcodeNormalizer.normalize(value),
        "mpn" | "part_number" => MpnNormalizer.normalize(value),
        "brand" => BrandNormalizer.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 }
        }
        FsComparatorType::GeohashPrefix => {
            // Encode both values as geohash (expect "lat,lon" format) and compare prefix overlap.
            let gh_a = parse_latlon_to_geohash(a, 8);
            let gh_b = parse_latlon_to_geohash(b, 8);
            let max_len = gh_a.len().max(gh_b.len());
            if max_len == 0 { return 0.0; }
            let shared = gh_a.chars().zip(gh_b.chars()).take_while(|(ca, cb)| ca == cb).count();
            shared as f64 / max_len as f64
        }
        FsComparatorType::NumericDistance => {
            let va: f64 = match a.parse() { Ok(v) => v, Err(_) => return 0.0 };
            let vb: f64 = match b.parse() { Ok(v) => v, Err(_) => return 0.0 };
            let diff = (va - vb).abs();
            let scale = va.abs().max(vb.abs()).max(1.0);
            (1.0 - diff / scale).max(0.0)
        }
        FsComparatorType::EmbeddingCosine => {
            embedding_cosine_similarity(a, b)
        }
        FsComparatorType::SpeedConstraint => {
            speed_constraint_score(a, b)
        }
    }
}

/// Parse a "lat,lon" string into a geohash of the given precision.
/// Falls back to the raw string if parsing fails.
fn parse_latlon_to_geohash(s: &str, precision: usize) -> String {
    let parts: Vec<&str> = s.split(',').collect();
    if parts.len() == 2 {
        if let (Ok(lat), Ok(lon)) = (parts[0].trim().parse::<f64>(), parts[1].trim().parse::<f64>()) {
            return encode_geohash(lat, lon, precision);
        }
    }
    // Already a geohash string, use as-is
    s.to_string()
}

/// 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 }
}

/// Score with optional semantic subtype inference and constraint application.
///
/// Wraps `score_pair` and additionally runs the semantic engine to determine
/// subtype inference and the final resolution decision. This is used by the
/// real-time resolve endpoint to produce Relate decisions inline.
///
/// The `incoming_entity` and `candidate_entity` are `NormalizedEntity` structs
/// needed for subtype inference (which reads entity data fields).
pub fn score_pair_with_semantics(
    incoming: &HashMap<String, String>,
    candidate: &HashMap<String, String>,
    fs_plan: &CompiledFellegiSunterPlan,
    semantic_engine: &crate::engine::semantic::SemanticEngine,
    incoming_entity: &crate::engine::types::NormalizedEntity,
    candidate_entity: &crate::engine::types::NormalizedEntity,
    match_threshold: f64,
    review_threshold: Option<f64>,
) -> RealtimeScoreWithSemantics {
    use super::types::{Decision, ResolutionDecision};

    let score = score_pair(incoming, candidate, fs_plan);

    // Determine base decision from composite score
    let decision = if score.composite >= match_threshold {
        Decision::Merge
    } else if review_threshold.map_or(false, |rt| score.composite >= rt) {
        Decision::Review
    } else {
        Decision::NoMerge
    };

    if !semantic_engine.is_active() {
        // No semantic layer - derive resolution directly from decision
        let resolution = match decision {
            Decision::Merge => ResolutionDecision::Merge,
            Decision::Review => ResolutionDecision::Review,
            Decision::NoMerge => ResolutionDecision::NoMatch,
        };
        return RealtimeScoreWithSemantics {
            score,
            subtype_incoming: None,
            subtype_candidate: None,
            resolution,
        };
    }

    let st_incoming = semantic_engine.infer_subtype(incoming_entity);
    let st_candidate = semantic_engine.infer_subtype(candidate_entity);
    let resolution = semantic_engine.apply_constraints(&decision, &st_incoming, &st_candidate);

    RealtimeScoreWithSemantics {
        score,
        subtype_incoming: st_incoming,
        subtype_candidate: st_candidate,
        resolution,
    }
}

/// Default person-type blocking keys (fallback when no plan exists).
///
/// Replicates the legacy hardcoded blocking behavior so tenants without
/// an identity plan still get email/phone/name/company blocking.
pub fn default_person_blocking_keys() -> Vec<cannon_common::ir::BlockingKey> {
    use cannon_common::ir::{BlockingKey, BlockingTransformation};
    vec![
        BlockingKey { fields: vec!["email".to_string()], transformations: vec![BlockingTransformation::Lowercase] },
        BlockingKey { fields: vec!["phone".to_string()], transformations: vec![BlockingTransformation::NormalizePhone] },
        BlockingKey { fields: vec!["first_name".to_string(), "last_name".to_string()], transformations: vec![BlockingTransformation::Lowercase] },
        BlockingKey { fields: vec!["last_name".to_string(), "dob".to_string()], transformations: vec![BlockingTransformation::Lowercase] },
        BlockingKey { fields: vec!["company_name".to_string(), "last_name".to_string()], transformations: vec![BlockingTransformation::Lowercase] },
    ]
}

/// Generate blocking keys from plan or fallback to person defaults.
///
/// Returns `Vec<String>` composite key strings suitable for GIN-indexed
/// `blocking_keys && $array` overlap queries on canonical_entities.
pub fn generate_blocking_keys_from_plan(
    data: &HashMap<String, String>,
    blocking: Option<&cannon_common::ir::CompiledBlocking>,
) -> Vec<String> {
    use crate::engine::blocking::compute_blocking_keys;

    let defaults = default_person_blocking_keys();
    let key_defs = match blocking {
        Some(b) if !b.keys.is_empty() => &b.keys,
        _ => &defaults,
    };
    compute_blocking_keys(key_defs, data)
}

/// Generate blocking keys for a record based on its normalized fields.
/// Returns a list of (block_type, block_value) pairs for SQL lookup.
///
/// Deprecated: use `generate_blocking_keys_from_plan` for plan-aware blocking.
/// Kept for backward compatibility with match_explain.
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::*;
    use crate::engine::geospatial::{embedding_cosine_similarity, speed_constraint_score};

    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"));
    }

    #[test]
    fn test_generate_blocking_keys_from_plan_no_plan() {
        // Without a plan, falls back to person defaults (email, phone, name, company)
        let data: HashMap<String, String> = [
            ("email".into(), "Bob@Gmail.com".into()),
            ("phone".into(), "+1-555-123-4567".into()),
            ("first_name".into(), "Bob".into()),
            ("last_name".into(), "Smith".into()),
        ].into();

        let keys = generate_blocking_keys_from_plan(&data, None);
        assert!(!keys.is_empty(), "Should produce blocking keys from defaults");
        // Email key should be lowercased
        assert!(keys.iter().any(|k| k.contains("bob@gmail.com")), "Should have email-based key");
    }

    #[test]
    fn test_generate_blocking_keys_from_plan_with_plan() {
        use cannon_common::ir::{BlockingKey, BlockingTransformation, CompiledBlocking, BlockingStrategy as BS};

        let blocking = CompiledBlocking {
            strategy: BS::Composite,
            keys: vec![
                BlockingKey {
                    fields: vec!["barcode".to_string()],
                    transformations: vec![BlockingTransformation::NormalizeBarcode],
                },
            ],
            fallback_sample_rate: None,
            lsh_bands: None,
            lsh_rows: None,
            neighborhood_window: None,
            sort_fields: vec![],
        };

        let data: HashMap<String, String> = [
            ("barcode".into(), "012345678905".into()),
            ("brand".into(), "Samsung".into()),
        ].into();

        let keys = generate_blocking_keys_from_plan(&data, Some(&blocking));
        assert!(!keys.is_empty(), "Should produce blocking keys from plan");
        // Should NOT produce person-type keys (email/phone) since plan is provided
        assert!(!keys.iter().any(|k| k.contains("email")), "Should not have email key with product plan");
    }

    #[test]
    fn test_default_person_blocking_keys_structure() {
        let keys = default_person_blocking_keys();
        assert_eq!(keys.len(), 5, "Should have exactly 5 default person blocking keys");

        // Verify field names
        let field_sets: Vec<Vec<&str>> = keys.iter()
            .map(|k| k.fields.iter().map(|f| f.as_str()).collect())
            .collect();
        assert!(field_sets.contains(&vec!["email"]));
        assert!(field_sets.contains(&vec!["phone"]));
        assert!(field_sets.contains(&vec!["first_name", "last_name"]));
        assert!(field_sets.contains(&vec!["last_name", "dob"]));
        assert!(field_sets.contains(&vec!["company_name", "last_name"]));
    }

    #[test]
    fn test_generate_blocking_keys_from_plan_empty_keys_falls_back() {
        use cannon_common::ir::{CompiledBlocking, BlockingStrategy as BS};

        // Plan with empty keys list should fall back to person defaults
        let blocking = CompiledBlocking {
            strategy: BS::Composite,
            keys: vec![],  // empty!
            fallback_sample_rate: None,
            lsh_bands: None,
            lsh_rows: None,
            neighborhood_window: None,
            sort_fields: vec![],
        };

        let data: HashMap<String, String> = [
            ("email".into(), "test@example.com".into()),
            ("first_name".into(), "Alice".into()),
            ("last_name".into(), "Wong".into()),
        ].into();

        let with_empty = generate_blocking_keys_from_plan(&data, Some(&blocking));
        let with_none = generate_blocking_keys_from_plan(&data, None);
        assert_eq!(with_empty, with_none,
            "Empty plan keys should produce same result as no plan");
        assert!(!with_empty.is_empty());
    }

    #[test]
    fn test_generate_blocking_keys_from_plan_consistent_with_compute() {
        use cannon_common::ir::{BlockingKey, BlockingTransformation, CompiledBlocking, BlockingStrategy as BS};
        use crate::engine::blocking::compute_blocking_keys;

        let keys_def = vec![
            BlockingKey {
                fields: vec!["email".to_string()],
                transformations: vec![BlockingTransformation::Lowercase],
            },
            BlockingKey {
                fields: vec!["last_name".to_string()],
                transformations: vec![BlockingTransformation::Soundex],
            },
        ];
        let blocking = CompiledBlocking {
            strategy: BS::Composite,
            keys: keys_def.clone(),
            fallback_sample_rate: None,
            lsh_bands: None,
            lsh_rows: None,
            neighborhood_window: None,
            sort_fields: vec![],
        };

        let data: HashMap<String, String> = [
            ("email".into(), "Test@Foo.com".into()),
            ("last_name".into(), "Johnson".into()),
        ].into();

        let from_plan = generate_blocking_keys_from_plan(&data, Some(&blocking));
        let direct = compute_blocking_keys(&keys_def, &data);
        assert_eq!(from_plan, direct,
            "Plan wrapper and direct call should produce identical keys");
    }

    #[test]
    fn test_generate_blocking_keys_from_plan_empty_data() {
        let data: HashMap<String, String> = HashMap::new();
        let keys = generate_blocking_keys_from_plan(&data, None);
        assert!(keys.is_empty(), "Empty data should produce no keys even with defaults");
    }

    // ---------------------------------------------------------------
    // score_pair_with_semantics
    // ---------------------------------------------------------------

    fn make_test_entity(id: uuid::Uuid, data: Vec<(&str, &str)>) -> crate::engine::types::NormalizedEntity {
        crate::engine::types::NormalizedEntity {
            id,
            tenant_id: uuid::Uuid::nil(),
            external_id: "test".to_string(),
            entity_type: "product".to_string(),
            data: data.into_iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
            source_name: "test".to_string(),
            valid_from: None,
            valid_to: None,
            last_updated: chrono::Utc::now(),
        }
    }

    #[test]
    fn test_score_pair_with_semantics_inactive_engine_merge() {
        let plan = make_fs_plan();
        let engine = crate::engine::semantic::SemanticEngine::inactive();

        let incoming_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("email", "bob@gmail.com"), ("first_name", "bob"), ("last_name", "smith"),
        ]);
        let candidate_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("email", "bob@gmail.com"), ("first_name", "bob"), ("last_name", "smith"),
        ]);

        let incoming: HashMap<String, String> = incoming_entity.data.clone();
        let candidate: HashMap<String, String> = candidate_entity.data.clone();

        let result = score_pair_with_semantics(
            &incoming, &candidate, &plan, &engine,
            &incoming_entity, &candidate_entity,
            plan.match_threshold, Some(plan.possible_threshold),
        );

        // Score above match_threshold -> Merge
        assert!(result.score.composite >= plan.match_threshold);
        assert_eq!(result.resolution, crate::engine::types::ResolutionDecision::Merge);
        assert!(result.subtype_incoming.is_none());
        assert!(result.subtype_candidate.is_none());
    }

    #[test]
    fn test_score_pair_with_semantics_inactive_engine_nomatch() {
        let plan = make_fs_plan();
        let engine = crate::engine::semantic::SemanticEngine::inactive();

        let incoming_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("email", "alice@example.com"), ("first_name", "alice"), ("last_name", "jones"),
        ]);
        let candidate_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("email", "bob@gmail.com"), ("first_name", "robert"), ("last_name", "smith"),
        ]);

        let incoming: HashMap<String, String> = incoming_entity.data.clone();
        let candidate: HashMap<String, String> = candidate_entity.data.clone();

        let result = score_pair_with_semantics(
            &incoming, &candidate, &plan, &engine,
            &incoming_entity, &candidate_entity,
            plan.match_threshold, Some(plan.possible_threshold),
        );

        assert!(result.score.composite < plan.possible_threshold);
        assert_eq!(result.resolution, crate::engine::types::ResolutionDecision::NoMatch);
    }

    #[test]
    fn test_score_pair_with_semantics_inactive_engine_review() {
        let plan = make_fs_plan();
        let engine = crate::engine::semantic::SemanticEngine::inactive();

        // Same last name, different first name and email -> moderate score
        let incoming_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("email", "bob@gmail.com"), ("first_name", "bob"), ("last_name", "smith"),
        ]);
        let candidate_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("email", "different@gmail.com"), ("first_name", "robert"), ("last_name", "smith"),
        ]);

        let incoming: HashMap<String, String> = incoming_entity.data.clone();
        let candidate: HashMap<String, String> = candidate_entity.data.clone();

        let result = score_pair_with_semantics(
            &incoming, &candidate, &plan, &engine,
            &incoming_entity, &candidate_entity,
            plan.match_threshold, Some(plan.possible_threshold),
        );

        // Check composite falls between possible and match thresholds
        if result.score.composite >= plan.possible_threshold && result.score.composite < plan.match_threshold {
            assert_eq!(result.resolution, crate::engine::types::ResolutionDecision::Review);
        }
        // Either way, verify no subtypes inferred with inactive engine
        assert!(result.subtype_incoming.is_none());
        assert!(result.subtype_candidate.is_none());
    }

    #[test]
    fn test_score_pair_with_semantics_review_threshold_none() {
        let plan = make_fs_plan();
        let engine = crate::engine::semantic::SemanticEngine::inactive();

        let incoming_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("email", "different@example.com"), ("first_name", "bob"), ("last_name", "smith"),
        ]);
        let candidate_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("email", "bob@gmail.com"), ("first_name", "bob"), ("last_name", "smith"),
        ]);

        let incoming: HashMap<String, String> = incoming_entity.data.clone();
        let candidate: HashMap<String, String> = candidate_entity.data.clone();

        let result = score_pair_with_semantics(
            &incoming, &candidate, &plan, &engine,
            &incoming_entity, &candidate_entity,
            plan.match_threshold, None, // no review threshold
        );

        // With review_threshold=None, anything below match is NoMerge -> NoMatch
        if result.score.composite < plan.match_threshold {
            assert_eq!(result.resolution, crate::engine::types::ResolutionDecision::NoMatch);
        }
    }

    #[test]
    fn test_score_pair_with_semantics_active_engine_relate() {
        use cannon_common::ir::*;

        // Build a plan with subtype rules and constraints
        let mut plan_data = cannon_common::ir::IdentityPlan {
            entity: "product".to_string(),
            plan_hash: "test".to_string(),
            identity_version: "test_v1".to_string(),
            sources: vec![],
            blocking: CompiledBlocking {
                strategy: BlockingStrategy::Composite,
                keys: vec![], fallback_sample_rate: None,
                lsh_bands: None, lsh_rows: None,
                neighborhood_window: None, sort_fields: vec![],
            },
            match_graph: MatchGraph { rules: vec![], leaf_count: 0 },
            survivorship: SurvivorshipPolicy::default(),
            decision: DecisionConfig {
                match_threshold: 0.8, review_threshold: None,
                reject_threshold: None, conflict_strategy: ConflictStrategy::default(),
                review_webhook: None, scoring_method: ScoringMethod::default(),
                ml_ensemble_config: None, custom_scoring_config: None,
                fellegi_sunter: None, tie_breaking: vec![],
                clustering: None, min_total_weight: 0.0,
                allow_single_field: vec![],
            },
            reference_identifiers: vec![],
            relations: vec![],
            exclusions: vec![],
            compliance: None, hierarchy: None, audit: None,
            metadata: None, governance: None,
            subtype_rules: vec![
                CompiledSubtypeRule {
                    subtype: "model".to_string(),
                    conditions: vec![SubtypeCondition {
                        field: "brand".to_string(),
                        operator: SubtypeOperator::Present,
                        value: None,
                    }],
                    priority: 1,
                },
                CompiledSubtypeRule {
                    subtype: "variant".to_string(),
                    conditions: vec![
                        SubtypeCondition { field: "brand".to_string(), operator: SubtypeOperator::Present, value: None },
                        SubtypeCondition { field: "color".to_string(), operator: SubtypeOperator::Present, value: None },
                    ],
                    priority: 5,
                },
            ],
            constraint_matrix: vec![
                CompiledConstraint {
                    subtype_a: "model".to_string(),
                    subtype_b: "variant".to_string(),
                    action: ConstraintAction::Relate {
                        link_type: "variant_of".to_string(),
                        direction: RelateDirection::BToA,
                    },
                },
            ],
            subtype_confidence_threshold: 0.7,
        };

        let engine = crate::engine::semantic::SemanticEngine::new(&plan_data);
        assert!(engine.is_active());

        let fs_plan = make_fs_plan();

        // Model entity (brand only) and variant entity (brand + color)
        let incoming_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("brand", "samsung"), ("email", "a@b.com"),
        ]);
        let candidate_entity = make_test_entity(uuid::Uuid::new_v4(), vec![
            ("brand", "samsung"), ("color", "blue"), ("email", "a@b.com"),
        ]);

        let incoming: HashMap<String, String> = incoming_entity.data.clone();
        let candidate: HashMap<String, String> = candidate_entity.data.clone();

        let result = score_pair_with_semantics(
            &incoming, &candidate, &fs_plan, &engine,
            &incoming_entity, &candidate_entity,
            fs_plan.match_threshold, Some(fs_plan.possible_threshold),
        );

        // Incoming has only brand -> model. Candidate has brand+color -> variant.
        assert!(result.subtype_incoming.is_some());
        assert_eq!(result.subtype_incoming.as_ref().unwrap().subtype, "model");
        assert!(result.subtype_candidate.is_some());
        assert_eq!(result.subtype_candidate.as_ref().unwrap().subtype, "variant");

        // Constraint (model, variant) -> Relate
        assert!(matches!(result.resolution, crate::engine::types::ResolutionDecision::Relate { ref link_type, .. }
            if link_type == "variant_of"));
    }

    #[test]
    fn test_embedding_cosine_identical() {
        let v = "0.1,0.2,0.3,0.4,0.5";
        let score = embedding_cosine_similarity(v, v);
        assert!((score - 1.0).abs() < 0.001, "Identical vectors should score 1.0, got {}", score);
    }

    #[test]
    fn test_embedding_cosine_orthogonal() {
        let a = "1.0,0.0,0.0";
        let b = "0.0,1.0,0.0";
        let score = embedding_cosine_similarity(a, b);
        assert!(score < 0.01, "Orthogonal vectors should score ~0.0, got {}", score);
    }

    #[test]
    fn test_embedding_cosine_similar() {
        let a = "0.9,0.1,0.0";
        let b = "0.85,0.15,0.05";
        let score = embedding_cosine_similarity(a, b);
        assert!(score > 0.95, "Similar vectors should score >0.95, got {}", score);
    }

    #[test]
    fn test_embedding_cosine_different_lengths() {
        let a = "0.1,0.2,0.3";
        let b = "0.1,0.2";
        let score = embedding_cosine_similarity(a, b);
        assert_eq!(score, 0.0, "Different length vectors should score 0.0");
    }

    #[test]
    fn test_speed_constraint_stationary() {
        // Same location, 60 seconds apart
        let a = "51.5074,-0.1278,1000.0";
        let b = "51.5074,-0.1278,1060.0";
        let score = speed_constraint_score(a, b);
        assert!((score - 1.0).abs() < 0.01, "Stationary should score ~1.0, got {}", score);
    }

    #[test]
    fn test_speed_constraint_plausible() {
        // ~340m apart, 60 seconds = ~5.7 m/s (walking fast)
        let a = "51.5074,-0.1278,1000.0";
        let b = "51.5080,-0.1230,1060.0";
        let score = speed_constraint_score(a, b);
        assert!(score > 0.5, "Walking speed should be plausible, got {}", score);
    }

    #[test]
    fn test_speed_constraint_impossible() {
        // London to Paris (~340km) in 60 seconds = ~5700 m/s
        let a = "51.5074,-0.1278,1000.0";
        let b = "48.8566,2.3522,1060.0";
        let score = speed_constraint_score(a, b);
        assert_eq!(score, 0.0, "Impossible speed should score 0.0, got {}", score);
    }

    #[test]
    fn test_speed_constraint_simultaneous_close() {
        // Same time, 10m apart
        let a = "51.50740,-0.12780,1000.0";
        let b = "51.50742,-0.12781,1000.0";
        let score = speed_constraint_score(a, b);
        assert_eq!(score, 1.0, "Simultaneous close obs should score 1.0, got {}", score);
    }

    #[test]
    fn test_speed_constraint_simultaneous_far() {
        // Same time, 1km apart
        let a = "51.5074,-0.1278,1000.0";
        let b = "51.5174,-0.1278,1000.0";
        let score = speed_constraint_score(a, b);
        assert_eq!(score, 0.0, "Simultaneous far obs should score 0.0, got {}", score);
    }
}
