// ---------------------------------------------------------------------------
// Plan building (port of Python bootstrap_spec, lines 370-440)
// ---------------------------------------------------------------------------

use super::blocking::infer_blocking_keys_for_entity;
use super::catalogs::catalog_for_entity;
use super::profile::{ColumnProfile, DataProfile};
use super::signals::{signal_from_canonical, EntityType, IdentitySignal};
use crate::ir::{
    BlockingKey, BlockingStrategy, BlockingTransformation, CompiledAudit, CompiledBlocking,
    CompiledClusteringConfig, CompiledEmTraining, CompiledFellegiSunterPlan, CompiledFsField,
    CompiledRule, ConflictStrategy, DecisionConfig, IdentityPlan, MatchGraph, PlanMetadata,
    ResolvedSource, ScoringMethod, SurvivorshipPolicy, SurvivorshipStrategy,
};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;

/// Build a compiled `IdentityPlan` from a data profile.
///
/// Only includes fields actually detected in the data. Sets FS priors,
/// comparators, normalizers, and blocking keys based on signal type.
pub fn build_identity_plan(
    profile: &DataProfile,
    source_names: &[String],
    identity_version: &str,
) -> IdentityPlan {
    build_identity_plan_with_entity(profile, source_names, identity_version, "sf_person", None)
}

/// Build a compiled `IdentityPlan` from a data profile with a custom entity name.
///
/// Parses entity_name into an EntityType, then uses that entity type's signal catalog
/// for field defaults, blocking keys, and field ordering. Falls back to Person defaults
/// for unknown entity names (backward compatible).
///
/// `user_mappings` allows users to declare field mappings the profiler cannot auto-detect.
/// Keys are canonical signal names (e.g. "product_name"), values are lists of source
/// column names that map to that signal (e.g. ["title", "description"]).
/// User mappings supplement auto-detected signals - they never override what the
/// engine already discovered from the data.
pub fn build_identity_plan_with_entity(
    profile: &DataProfile,
    source_names: &[String],
    identity_version: &str,
    entity_name: &str,
    user_mappings: Option<&HashMap<String, Vec<String>>>,
) -> IdentityPlan {
    // Resolve entity type: prefer the profile's inferred type, otherwise parse the name
    let entity_type = profile
        .inferred_entity_type
        .clone()
        .unwrap_or_else(|| EntityType::from_str_loose(entity_name));

    let catalog = catalog_for_entity(&entity_type);
    let detected = profile.detected_signals_for_entity(&entity_type);

    // Resolve user-declared field mappings: canonical signal name -> column names.
    // Only includes signals that (a) exist in the entity catalog and (b) were NOT
    // already auto-detected from data. Engine discovery always takes priority.
    let mut user_signals: Vec<IdentitySignal> = Vec::new();
    let user_mappings_resolved: HashMap<String, Vec<String>> = if let Some(mappings) = user_mappings {
        let mut resolved = HashMap::new();
        let detected_canonicals: HashSet<String> = detected
            .iter()
            .map(|(_, sig)| sig.canonical_name().to_string())
            .collect();

        for (canonical, columns) in mappings {
            if detected_canonicals.contains(canonical.as_str()) {
                continue;
            }
            if !catalog.field_defaults.contains_key(canonical.as_str()) {
                continue;
            }
            if let Some(signal) = signal_from_canonical(canonical) {
                user_signals.push(signal);
                resolved.insert(canonical.clone(), columns.clone());
            }
        }
        resolved
    } else {
        HashMap::new()
    };

    // Build FS fields and match rules from detected signals using entity catalog defaults
    let mut fs_fields = Vec::new();
    let mut rules = Vec::new();
    let pii_fields = Vec::new();

    // Build overlap lookup: canonical signal name -> cross_source_overlap from profile columns
    let overlap_lookup: HashMap<String, f64> = profile.columns.iter()
        .filter_map(|col| {
            let sig = col.signal.as_ref()?;
            let overlap = col.cross_source_overlap?;
            Some((sig.canonical_name().to_string(), overlap))
        })
        .collect();

    for (_col, signal) in &detected {
        let canonical = signal.canonical_name();
        if let Some(defaults) = catalog.field_defaults.get(canonical) {
            let m = defaults.m_probability;
            let u = defaults.u_probability;

            // Dynamic weight adjustment for SKU based on cross-source overlap:
            // - overlap > 5%: values are shared cross-source (manufacturer codes) -> weight 4.0
            // - overlap <= 5% or unknown: internal codes -> weight 1.0, EM adjusts
            // All other signals use the catalog weight as-is.
            let w = if canonical == "sku" {
                match overlap_lookup.get("sku") {
                    Some(&overlap) if overlap > 0.05 => 4.0,
                    Some(_) => 1.0,
                    None => defaults.weight, // no overlap data (single source) -> use catalog
                }
            } else {
                defaults.weight
            };

            let w_agree = (m / u).ln() * w;
            let w_disagree = ((1.0 - m) / (1.0 - u)).ln() * w;

            fs_fields.push(CompiledFsField {
                name: canonical.to_string(),
                comparator: defaults.comparator.clone(),
                weight: w,
                m_probability: m,
                u_probability: u,
                w_agree,
                w_disagree,
                normalizer: Some(defaults.normalizer.to_string()),
            });

            rules.push(CompiledRule {
                name: canonical.to_string(),
                rule_type: defaults.rule_comparator.clone(),
            });

        }
    }

    // Add FS fields and rules for user-declared signals (not auto-detected).
    // These use catalog defaults - no overlap data since the profiler didn't find them.
    for signal in &user_signals {
        let canonical = signal.canonical_name();
        if let Some(defaults) = catalog.field_defaults.get(canonical) {
            let w = defaults.weight;
            let m = defaults.m_probability;
            let u = defaults.u_probability;
            let w_agree = (m / u).ln() * w;
            let w_disagree = ((1.0 - m) / (1.0 - u)).ln() * w;

            fs_fields.push(CompiledFsField {
                name: canonical.to_string(),
                comparator: defaults.comparator.clone(),
                weight: w,
                m_probability: m,
                u_probability: u,
                w_agree,
                w_disagree,
                normalizer: Some(defaults.normalizer.to_string()),
            });

            rules.push(CompiledRule {
                name: canonical.to_string(),
                rule_type: defaults.rule_comparator.clone(),
            });
        }
    }

    // Compute FS plan bounds
    let max_composite: f64 = fs_fields.iter().map(|f| f.w_agree).sum();
    let min_composite: f64 = fs_fields.iter().map(|f| f.w_disagree).sum();

    let fs_plan = CompiledFellegiSunterPlan {
        fields: fs_fields,
        match_threshold: 10.0,
        possible_threshold: 4.0,
        non_match_threshold: -5.0,
        merge_threshold: None,
        max_composite,
        min_composite,
        em_training: Some(CompiledEmTraining {
            max_iterations: 25,
            convergence_threshold: 0.001,
            estimate_u: Some("random_sampling".to_string()),
            max_random_pairs: 1_000_000,
            prior_weight: 0.7,
        }),
    };

    // Build blocking keys using entity-aware inference
    let blocking_keys = infer_blocking_keys_for_entity(profile, &entity_type);
    let compiled_blocking_keys: Vec<BlockingKey> = blocking_keys
        .into_iter()
        .map(|fields| {
            let transformations = fields
                .iter()
                .map(|f| match f.as_str() {
                    "email" => BlockingTransformation::NormalizeEmail,
                    "phone" => BlockingTransformation::NormalizePhone,
                    "brand" => BlockingTransformation::NormalizeBrand,
                    "product_name" => BlockingTransformation::TokenizeProduct,
                    "gtin" | "upc" | "ean" => BlockingTransformation::NormalizeBarcode,
                    "asin" => BlockingTransformation::Lowercase,
                    "mpn" | "model_number" => BlockingTransformation::NormalizeMpn,
                    _ => BlockingTransformation::Lowercase,
                })
                .collect();
            BlockingKey {
                fields,
                transformations,
            }
        })
        .collect();

    // Build a lookup: signal canonical name -> all column names with that signal.
    // Used for per-source field mapping: each source may have a different column
    // name for the same semantic signal (e.g. "product_name" vs "item_name").
    let mut signal_to_columns: HashMap<String, Vec<String>> = HashMap::new();
    for col in &profile.columns {
        if let Some(ref sig) = col.signal {
            signal_to_columns
                .entry(sig.canonical_name().to_string())
                .or_default()
                .push(col.name.clone());
        }
    }
    // Add user-declared column names to the signal lookup
    for (canonical, columns) in &user_mappings_resolved {
        let entry = signal_to_columns.entry(canonical.clone()).or_default();
        for col in columns {
            if !entry.contains(col) {
                entry.push(col.clone());
            }
        }
    }

    // Build sources with per-source field mappings
    let sources: Vec<ResolvedSource> = source_names
        .iter()
        .map(|name| {
            // For each source, map canonical signal -> the column name that
            // actually exists in this source's data. Falls back to the first
            // detected column if source_columns is not tracked (backward compat).
            let source_cols = profile.source_columns.get(name);
            let mut field_mappings: HashMap<String, String> = HashMap::new();
                for (col, sig) in &detected {
                    let canonical = sig.canonical_name().to_string();
                    // First detected column for a canonical signal wins.
                    // `detected` comes from detected_signals_ordered which already
                    // deduplicates (one entry per canonical signal, first column wins).
                    if field_mappings.contains_key(&canonical) {
                        continue;
                    }
                    if let Some(src_cols) = source_cols {
                        // Prefer the detected column name if it exists in this source
                        if src_cols.contains(&col.name) {
                            field_mappings.insert(canonical, col.name.clone());
                        } else if let Some(candidates) = signal_to_columns.get(&canonical) {
                            // Fallback: find any column for this signal in this source
                            for candidate in candidates {
                                if src_cols.contains(candidate) {
                                    field_mappings.insert(canonical, candidate.clone());
                                    break;
                                }
                            }
                        }
                    } else {
                        field_mappings.insert(canonical, col.name.clone());
                    }
                }

            // Add user-declared field mappings for this source.
            // For each user mapping (canonical -> [col1, col2, ...]),
            // find the first column that exists in this source's data.
            if let Some(src_cols) = source_cols {
                for (canonical, columns) in &user_mappings_resolved {
                    if field_mappings.contains_key(canonical) {
                        continue; // auto-detected mapping takes priority
                    }
                    for col in columns {
                        if src_cols.contains(col) {
                            field_mappings.insert(canonical.clone(), col.clone());
                            break;
                        }
                    }
                }
            }

            ResolvedSource {
                name: name.clone(),
                adapter: "database".to_string(),
                location: "external_entities".to_string(),
                id_field: "external_id".to_string(),
                field_mappings,
                temporal: None,
                freshness: None,
                schema: None,
                sampling: None,
                tags: Vec::new(),
                pii_fields: pii_fields.clone(),
                reliability: 1.0,
            }
        })
        .collect();

    // Compute plan hash
    let hash_input = format!(
        "{}:{}:{}",
        identity_version,
        source_names.join(","),
        detected
            .iter()
            .map(|(_, s)| s.canonical_name())
            .collect::<Vec<_>>()
            .join(",")
    );
    let mut hasher = Sha256::new();
    hasher.update(hash_input.as_bytes());
    let plan_hash = hex::encode(hasher.finalize());

    // Determine allow_single_field based on entity type
    let allow_single_field = match entity_type {
        EntityType::Person => vec!["email".to_string()],
        EntityType::Company => vec!["domain".to_string(), "tax_id".to_string()],
        EntityType::Product => vec!["gtin".to_string(), "upc".to_string(), "asin".to_string()],
        EntityType::Transaction => vec!["transaction_id".to_string()],
        EntityType::Healthcare => vec!["mrn".to_string(), "npi".to_string()],
        EntityType::Custom(_) => vec!["email".to_string()],
    };

    // Use the resolved entity type's name for the plan entity label.
    // This ensures the label matches the actual catalog used (blocking keys,
    // field defaults, etc.) rather than the caller's potentially stale name.
    let resolved_entity_name = entity_type.to_entity_name();

    IdentityPlan {
        entity: resolved_entity_name.to_string(),
        plan_hash,
        identity_version: identity_version.to_string(),
        sources,
        blocking: CompiledBlocking {
            strategy: BlockingStrategy::Composite,
            keys: compiled_blocking_keys,
            fallback_sample_rate: None,
            lsh_bands: None,
            lsh_rows: None,
            neighborhood_window: None,
            sort_fields: Vec::new(),
        },
        match_graph: MatchGraph {
            rules,
            leaf_count: detected.len(),
        },
        survivorship: SurvivorshipPolicy {
            default_strategy: SurvivorshipStrategy::MostComplete,
            field_policies: HashMap::new(),
        },
        decision: DecisionConfig {
            match_threshold: 0.9,
            review_threshold: Some(0.7),
            reject_threshold: None,
            conflict_strategy: ConflictStrategy::PreferHighConfidence,
            review_webhook: None,
            scoring_method: ScoringMethod::FellegiSunter,
            ml_ensemble_config: None,
            custom_scoring_config: None,
            fellegi_sunter: Some(fs_plan),
            tie_breaking: Vec::new(),
            clustering: Some(CompiledClusteringConfig {
                method: crate::ir::ClusteringMethod::Graph,
                min_edge_weight: 0.6,
                min_cluster_coherence: 0.6,
                max_cluster_size: 100,
            }),
            min_total_weight: 0.0,
            allow_single_field,
        },
        reference_identifiers: Vec::new(),
        relations: Vec::new(),
        exclusions: Vec::new(),
        compliance: None,
        hierarchy: None,
        audit: Some(CompiledAudit {
            log_all_comparisons: false,
            log_decisions: true,
            log_conflicts: true,
        }),
        metadata: Some(PlanMetadata {
            name: Some("Bootstrapped from data profiling".to_string()),
            description: Some(format!(
                "Auto-generated {} plan with {} detected fields",
                entity_type.display_name(),
                detected.len()
            )),
            owner: None,
            tags: vec!["bootstrapped".to_string(), entity_type.display_name().to_string()],
        }),
        governance: None,
        subtype_rules: Vec::new(),
        constraint_matrix: Vec::new(),
        subtype_confidence_threshold: 0.7,
    }
}

/// Build a placeholder plan with default 5-field schema for initial setup
/// (before any data has been synced). Metadata marks it as `placeholder`.
pub fn build_placeholder_plan(
    source_names: &[String],
    identity_version: &str,
) -> IdentityPlan {
    // Create a synthetic profile with all standard signals
    let columns = vec![
        ColumnProfile {
            name: "email".into(),
            signal: Some(IdentitySignal::Email),
            distinct_count: 0,
            null_count: 0,
            total_count: 1,
            sample_values: Vec::new(),
            cross_source_overlap: None,
        },
        ColumnProfile {
            name: "phone".into(),
            signal: Some(IdentitySignal::Phone),
            distinct_count: 0,
            null_count: 0,
            total_count: 1,
            sample_values: Vec::new(),
            cross_source_overlap: None,
        },
        ColumnProfile {
            name: "first_name".into(),
            signal: Some(IdentitySignal::FirstName),
            distinct_count: 0,
            null_count: 0,
            total_count: 1,
            sample_values: Vec::new(),
            cross_source_overlap: None,
        },
        ColumnProfile {
            name: "last_name".into(),
            signal: Some(IdentitySignal::LastName),
            distinct_count: 0,
            null_count: 0,
            total_count: 1,
            sample_values: Vec::new(),
            cross_source_overlap: None,
        },
        ColumnProfile {
            name: "company_name".into(),
            signal: Some(IdentitySignal::CompanyName),
            distinct_count: 0,
            null_count: 0,
            total_count: 1,
            sample_values: Vec::new(),
            cross_source_overlap: None,
        },
    ];

    let profile = DataProfile {
        tenant_id: Uuid::nil(),
        columns,
        total_records: 0,
        source_names: source_names.to_vec(),
        inferred_entity_type: Some(EntityType::Person),
        source_columns: HashMap::new(),
    };

    let mut plan = build_identity_plan(&profile, source_names, identity_version);

    // Mark as placeholder in metadata
    if let Some(ref mut meta) = plan.metadata {
        meta.tags.push("placeholder".to_string());
        meta.description = Some("Placeholder plan - will be rebuilt after first sync".to_string());
    }

    plan
}

#[cfg(test)]
mod tests {
    use super::*;
    use super::super::profile::{infer_entity_type, profile_from_rows_with_entity};
    use crate::ir::BlockingTransformation;
    use std::collections::HashMap;
    use uuid::Uuid;

    #[test]
    fn test_build_identity_plan_includes_detected_fields() {
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "email".into(),
                    signal: Some(IdentitySignal::Email),
                    distinct_count: 100,
                    null_count: 0,
                    total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "last_name".into(),
                    signal: Some(IdentitySignal::LastName),
                    distinct_count: 60,
                    null_count: 0,
                    total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["sf_contact".into()],
            inferred_entity_type: Some(EntityType::Person),
            source_columns: HashMap::new(),
        };

        let plan = build_identity_plan(
            &profile,
            &["sf_contact".to_string()],
            "sf_prism_v3",
        );

        assert_eq!(plan.identity_version, "sf_prism_v3");
        // Entity name resolved from inferred_entity_type (Person -> "person"),
        // not the caller's "sf_person" string
        assert_eq!(plan.entity, "person");
        assert_eq!(plan.sources.len(), 1);
        assert_eq!(plan.sources[0].name, "sf_contact");

        // FS plan should have 2 fields
        let fs = plan.decision.fellegi_sunter.as_ref().unwrap();
        assert_eq!(fs.fields.len(), 2);
        assert_eq!(fs.fields[0].name, "email");
        assert_eq!(fs.fields[1].name, "last_name");

        // Match rules
        assert_eq!(plan.match_graph.rules.len(), 2);
    }

    #[test]
    fn test_build_placeholder_plan() {
        let plan = build_placeholder_plan(
            &["sf_contact".to_string(), "sf_lead".to_string()],
            "sf_prism_v3",
        );

        assert_eq!(plan.sources.len(), 2);
        let fs = plan.decision.fellegi_sunter.as_ref().unwrap();
        assert_eq!(fs.fields.len(), 5); // All 5 default signals
        assert!(plan
            .metadata
            .as_ref()
            .unwrap()
            .tags
            .contains(&"placeholder".to_string()));
    }

    #[test]
    fn test_build_product_plan() {
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "sku".into(),
                    signal: Some(IdentitySignal::Sku),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "product_name".into(),
                    signal: Some(IdentitySignal::ProductName),
                    distinct_count: 80, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["shopify".into()],
            inferred_entity_type: Some(EntityType::Product),
            source_columns: HashMap::new(),
        };

        let plan = build_identity_plan_with_entity(
            &profile,
            &["shopify".to_string()],
            "v1",
            "product",
            None,
        );

        // Should use product catalog defaults
        let fs = plan.decision.fellegi_sunter.as_ref().unwrap();
        assert_eq!(fs.fields.len(), 2);
        assert_eq!(fs.fields[0].name, "sku");
        assert_eq!(fs.fields[1].name, "product_name");
        // SKU weight should be 2.5 (from product catalog, single source - no overlap data)
        assert!((fs.fields[0].weight - 2.5).abs() < 0.01);
        // allow_single_field should include gtin, upc, asin (not sku - internal codes shouldn't be sole match)
        assert!(plan.decision.allow_single_field.contains(&"gtin".to_string()));
        assert!(plan.decision.allow_single_field.contains(&"upc".to_string()));
        assert!(plan.decision.allow_single_field.contains(&"asin".to_string()));
        assert!(!plan.decision.allow_single_field.contains(&"sku".to_string()));
    }

    #[test]
    fn test_plan_entity_matches_inferred_type() {
        // When profiler infers Product, the plan entity should say "product" not the caller's name
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "sku".into(),
                    signal: Some(IdentitySignal::Sku),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "product_name".into(),
                    signal: Some(IdentitySignal::ProductName),
                    distinct_count: 80, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["shopify".into()],
            inferred_entity_type: Some(EntityType::Product),
            source_columns: HashMap::new(),
        };

        // Caller passes "person" but profile says Product - plan should use "product"
        let plan = build_identity_plan_with_entity(
            &profile,
            &["shopify".to_string()],
            "v1",
            "person",
            None,
        );
        assert_eq!(plan.entity, "product");
    }

    #[test]
    fn test_product_blocking_uses_tokenize_product() {
        // Product plan should use TokenizeProduct for product_name blocking
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "sku".into(),
                    signal: Some(IdentitySignal::Sku),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "brand".into(),
                    signal: Some(IdentitySignal::Brand),
                    distinct_count: 20, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "product_name".into(),
                    signal: Some(IdentitySignal::ProductName),
                    distinct_count: 80, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "category".into(),
                    signal: Some(IdentitySignal::Category),
                    distinct_count: 10, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["store".into()],
            inferred_entity_type: Some(EntityType::Product),
            source_columns: HashMap::new(),
        };

        let plan = build_identity_plan_with_entity(
            &profile,
            &["store".to_string()],
            "v1",
            "product",
            None,
        );

        // Find the [brand, product_name] blocking key
        let brand_pn_key = plan.blocking.keys.iter()
            .find(|k| k.fields == vec!["brand", "product_name"])
            .expect("should have [brand, product_name] blocking key");

        // brand should use NormalizeBrand, product_name should use TokenizeProduct
        assert_eq!(brand_pn_key.transformations[0], BlockingTransformation::NormalizeBrand);
        assert_eq!(brand_pn_key.transformations[1], BlockingTransformation::TokenizeProduct);

        // sku should use Lowercase (default for generic exact fields)
        let sku_key = plan.blocking.keys.iter()
            .find(|k| k.fields == vec!["sku"])
            .expect("should have [sku] blocking key");
        assert_eq!(sku_key.transformations[0], BlockingTransformation::Lowercase);
    }

    #[test]
    fn test_blocking_transformations_gtin_and_asin() {
        // GTIN blocking should use NormalizeBarcode, ASIN should use Lowercase
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "gtin".into(),
                    signal: Some(IdentitySignal::Gtin),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "asin".into(),
                    signal: Some(IdentitySignal::Asin),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "brand".into(),
                    signal: Some(IdentitySignal::Brand),
                    distinct_count: 20, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["feed".into()],
            inferred_entity_type: Some(EntityType::Product),
            source_columns: HashMap::new(),
        };

        let plan = build_identity_plan_with_entity(&profile, &["feed".to_string()], "v1", "product", None);

        let gtin_key = plan.blocking.keys.iter()
            .find(|k| k.fields == vec!["gtin"])
            .expect("should have [gtin] blocking key");
        assert_eq!(gtin_key.transformations[0], BlockingTransformation::NormalizeBarcode);

        let asin_key = plan.blocking.keys.iter()
            .find(|k| k.fields == vec!["asin"])
            .expect("should have [asin] blocking key");
        assert_eq!(asin_key.transformations[0], BlockingTransformation::Lowercase);
    }

    #[test]
    fn test_per_source_field_mappings_missing_signal() {
        // Source A has sku+brand, Source B has only brand (no sku column).
        // Source B's field_mappings should NOT include sku.
        let rows: Vec<(serde_json::Value, String)> = vec![
            (serde_json::json!({"sku": "MFG-001", "brand": "Nike", "product_name": "Air Max"}), "source_a".into()),
            (serde_json::json!({"sku": "MFG-002", "brand": "Adidas", "product_name": "Ultraboost"}), "source_a".into()),
            (serde_json::json!({"brand": "Nike", "product_name": "Air Force 1"}), "source_b".into()),
            (serde_json::json!({"brand": "Adidas", "product_name": "Stan Smith"}), "source_b".into()),
        ];

        let profile = profile_from_rows_with_entity(Uuid::nil(), &rows, Some(EntityType::Product));
        let plan = build_identity_plan_with_entity(
            &profile,
            &["source_a".to_string(), "source_b".to_string()],
            "v1",
            "product",
            None,
        );

        let source_a = plan.sources.iter().find(|s| s.name == "source_a").unwrap();
        let source_b = plan.sources.iter().find(|s| s.name == "source_b").unwrap();

        // Source A should have sku in its mappings
        assert!(source_a.field_mappings.contains_key("sku"),
            "Source A should have sku mapping");
        // Source B should NOT have sku (it doesn't have that column)
        assert!(!source_b.field_mappings.contains_key("sku"),
            "Source B should not have sku mapping (column not present)");
        // Both should have brand
        assert!(source_a.field_mappings.contains_key("brand"));
        assert!(source_b.field_mappings.contains_key("brand"));
    }

    #[test]
    fn test_sku_weight_high_with_overlap() {
        // SKU with cross-source overlap > 5% should get weight 4.0
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "sku".into(),
                    signal: Some(IdentitySignal::Sku),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: Some(0.15), // 15% overlap -> manufacturer code
                },
                ColumnProfile {
                    name: "brand".into(),
                    signal: Some(IdentitySignal::Brand),
                    distinct_count: 20, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["jd_sports".into(), "wholesale".into()],
            inferred_entity_type: Some(EntityType::Product),
            source_columns: HashMap::new(),
        };

        let plan = build_identity_plan_with_entity(&profile, &["jd_sports".to_string()], "v1", "product", None);
        let fs = plan.decision.fellegi_sunter.as_ref().unwrap();
        let sku_field = fs.fields.iter().find(|f| f.name == "sku").unwrap();
        assert!((sku_field.weight - 4.0).abs() < 0.01, "Expected weight 4.0, got {}", sku_field.weight);
    }

    #[test]
    fn test_sku_weight_low_without_overlap() {
        // SKU with no cross-source overlap should get weight 1.0
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "sku".into(),
                    signal: Some(IdentitySignal::Sku),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: Some(0.0), // 0% overlap -> internal SKU
                },
                ColumnProfile {
                    name: "brand".into(),
                    signal: Some(IdentitySignal::Brand),
                    distinct_count: 20, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["store".into(), "supplier".into()],
            inferred_entity_type: Some(EntityType::Product),
            source_columns: HashMap::new(),
        };

        let plan = build_identity_plan_with_entity(&profile, &["store".to_string()], "v1", "product", None);
        let fs = plan.decision.fellegi_sunter.as_ref().unwrap();
        let sku_field = fs.fields.iter().find(|f| f.name == "sku").unwrap();
        assert!((sku_field.weight - 1.0).abs() < 0.01, "Expected weight 1.0, got {}", sku_field.weight);
    }

    #[test]
    fn test_sku_weight_at_boundary_overlap() {
        // Overlap exactly at 5% threshold - should get weight 1.0 (not 4.0)
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "sku".into(),
                    signal: Some(IdentitySignal::Sku),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: Some(0.05), // exactly at boundary
                },
                ColumnProfile {
                    name: "brand".into(),
                    signal: Some(IdentitySignal::Brand),
                    distinct_count: 20, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["a".into(), "b".into()],
            inferred_entity_type: Some(EntityType::Product),
            source_columns: HashMap::new(),
        };

        let plan = build_identity_plan_with_entity(&profile, &["a".to_string()], "v1", "product", None);
        let fs = plan.decision.fellegi_sunter.as_ref().unwrap();
        let sku_field = fs.fields.iter().find(|f| f.name == "sku").unwrap();
        assert!((sku_field.weight - 1.0).abs() < 0.01,
            "Overlap at boundary (0.05) should get weight 1.0, got {}", sku_field.weight);
    }

    #[test]
    fn test_asin_weight_in_product_catalog() {
        let catalog = catalog_for_entity(&EntityType::Product);
        let asin_defaults = catalog.field_defaults.get("asin").unwrap();
        assert!((asin_defaults.weight - 4.0).abs() < 0.01);
    }

    #[test]
    fn test_allow_single_field_product() {
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "gtin".into(),
                    signal: Some(IdentitySignal::Gtin),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["feed".into()],
            inferred_entity_type: Some(EntityType::Product),
            source_columns: HashMap::new(),
        };

        let plan = build_identity_plan_with_entity(&profile, &["feed".to_string()], "v1", "product", None);
        assert!(plan.decision.allow_single_field.contains(&"gtin".to_string()));
        assert!(plan.decision.allow_single_field.contains(&"upc".to_string()));
        assert!(plan.decision.allow_single_field.contains(&"asin".to_string()));
        assert!(!plan.decision.allow_single_field.contains(&"sku".to_string()));
    }

    #[test]
    fn test_infer_entity_type_with_asin() {
        let profile = DataProfile {
            tenant_id: Uuid::nil(),
            columns: vec![
                ColumnProfile {
                    name: "asin".into(),
                    signal: Some(IdentitySignal::Asin),
                    distinct_count: 100, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
                ColumnProfile {
                    name: "product_name".into(),
                    signal: Some(IdentitySignal::ProductName),
                    distinct_count: 80, null_count: 0, total_count: 100,
                    sample_values: Vec::new(),
                    cross_source_overlap: None,
                },
            ],
            total_records: 100,
            source_names: vec!["amazon".into()],
            inferred_entity_type: None,
            source_columns: HashMap::new(),
        };
        assert_eq!(infer_entity_type(&profile), EntityType::Product);
    }

    #[test]
    fn test_user_mappings_add_product_name() {
        // SKU is auto-detected, but product_name is NOT (columns are "title" and "description").
        // User declares --map "product_name=title,description".
        let rows: Vec<(serde_json::Value, String)> = vec![
            (serde_json::json!({"sku": "MFG-001", "title": "Widget A", "brand": "Acme"}), "catalog".into()),
            (serde_json::json!({"sku": "MFG-002", "title": "Widget B", "brand": "Acme"}), "catalog".into()),
            (serde_json::json!({"sku": "MFG-003", "description": "Gadget X", "brand": "Beta"}), "marketplace".into()),
            (serde_json::json!({"sku": "MFG-004", "description": "Gadget Y", "brand": "Beta"}), "marketplace".into()),
        ];

        let profile = profile_from_rows_with_entity(Uuid::nil(), &rows, Some(EntityType::Product));

        // Without user mappings: no product_name in plan
        let plan_without = build_identity_plan_with_entity(
            &profile,
            &["catalog".to_string(), "marketplace".to_string()],
            "v1",
            "product",
            None,
        );
        let fs_without = plan_without.decision.fellegi_sunter.as_ref().unwrap();
        assert!(
            !fs_without.fields.iter().any(|f| f.name == "product_name"),
            "Without user mappings, product_name should not be in FS fields"
        );

        // With user mappings: product_name appears
        let user_mappings = HashMap::from([
            ("product_name".to_string(), vec!["title".to_string(), "description".to_string()]),
        ]);
        let plan_with = build_identity_plan_with_entity(
            &profile,
            &["catalog".to_string(), "marketplace".to_string()],
            "v1",
            "product",
            Some(&user_mappings),
        );
        let fs_with = plan_with.decision.fellegi_sunter.as_ref().unwrap();
        assert!(
            fs_with.fields.iter().any(|f| f.name == "product_name"),
            "With user mappings, product_name should be in FS fields"
        );

        // Per-source mappings: catalog maps product_name -> title, marketplace -> description
        let catalog_src = plan_with.sources.iter().find(|s| s.name == "catalog").unwrap();
        let marketplace_src = plan_with.sources.iter().find(|s| s.name == "marketplace").unwrap();
        assert_eq!(
            catalog_src.field_mappings.get("product_name"),
            Some(&"title".to_string()),
            "catalog should map product_name -> title"
        );
        assert_eq!(
            marketplace_src.field_mappings.get("product_name"),
            Some(&"description".to_string()),
            "marketplace should map product_name -> description"
        );
    }

    #[test]
    fn test_user_mappings_dont_override_auto_detected() {
        // Brand is auto-detected from column "brand". User also declares
        // --map "brand=maker". The user mapping should be ignored since
        // brand was already discovered by the engine.
        let rows: Vec<(serde_json::Value, String)> = vec![
            (serde_json::json!({"sku": "MFG-001", "brand": "Nike", "maker": "Nike Inc"}), "store".into()),
            (serde_json::json!({"sku": "MFG-002", "brand": "Adidas", "maker": "Adidas AG"}), "store".into()),
        ];

        let profile = profile_from_rows_with_entity(Uuid::nil(), &rows, Some(EntityType::Product));

        // Without user mappings
        let plan_without = build_identity_plan_with_entity(
            &profile,
            &["store".to_string()],
            "v1",
            "product",
            None,
        );

        // With user mappings that try to override brand
        let user_mappings = HashMap::from([
            ("brand".to_string(), vec!["maker".to_string()]),
        ]);
        let plan_with = build_identity_plan_with_entity(
            &profile,
            &["store".to_string()],
            "v1",
            "product",
            Some(&user_mappings),
        );

        // The FS field count should be the same - user mapping for an already
        // auto-detected signal should not add a duplicate field
        let fs_without = plan_without.decision.fellegi_sunter.as_ref().unwrap();
        let fs_with = plan_with.decision.fellegi_sunter.as_ref().unwrap();
        assert_eq!(
            fs_without.fields.len(),
            fs_with.fields.len(),
            "User mapping for auto-detected signal should not add duplicate FS fields"
        );

        // Brand should still map to "brand" (auto-detected column), not "maker"
        let store = plan_with.sources.iter().find(|s| s.name == "store").unwrap();
        assert_eq!(
            store.field_mappings.get("brand"),
            Some(&"brand".to_string()),
            "Auto-detected brand should not be overridden by user mapping"
        );
    }

    #[test]
    fn test_user_mappings_ignored_for_unknown_signals() {
        // User declares --map "foobar=col1". "foobar" is not in any entity catalog.
        let rows: Vec<(serde_json::Value, String)> = vec![
            (serde_json::json!({"sku": "MFG-001", "col1": "hello"}), "feed".into()),
            (serde_json::json!({"sku": "MFG-002", "col1": "world"}), "feed".into()),
        ];

        let profile = profile_from_rows_with_entity(Uuid::nil(), &rows, Some(EntityType::Product));

        let user_mappings = HashMap::from([
            ("foobar".to_string(), vec!["col1".to_string()]),
        ]);
        let plan = build_identity_plan_with_entity(
            &profile,
            &["feed".to_string()],
            "v1",
            "product",
            Some(&user_mappings),
        );

        let fs = plan.decision.fellegi_sunter.as_ref().unwrap();
        assert!(
            !fs.fields.iter().any(|f| f.name == "foobar"),
            "Unknown signal 'foobar' should not appear in FS fields"
        );
    }
}
