//! Decision Cascade Tests
//!
//! Tests the three-layer decision cascade:
//! Layer 1: Hard Negative Evidence (exact field mismatch -> immediate NoMerge)
//! Layer 2: Semantic Constraints (Block/Relate resolved before thresholding)
//! Layer 3: Statistical Thresholding (only for genuinely ambiguous pairs)

use cannon_common::spec::IdentitySpec;
use cannon_core::engine::ReconciliationEngine;
use cannon_core::engine::types::{NormalizedEntity, ResolutionDecision, Decision};
use cannon_core::engine::compiler::SpecCompiler;
use cannon_core::engine::overrides::OverrideResolver;
use cannon_core::engine::matching::ExactMatch;
use uuid::Uuid;
use chrono::Utc;

fn make_entity(ext_id: &str, data: Vec<(&str, &str)>) -> NormalizedEntity {
    NormalizedEntity {
        id: Uuid::new_v4(),
        tenant_id: Uuid::new_v4(),
        external_id: ext_id.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: Utc::now(),
    }
}

// ============================================================================
// Layer 1: Hard Negative Evidence
// ============================================================================

#[test]
fn test_hard_negative_barcode_mismatch() {
    // Two products that share brand (blocking key) but have different barcodes.
    // Layer 1 should immediately reject without scoring.
    // Hard negative requires an active semantic engine (subtypes), so use a spec.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    let a = make_entity("sku1", vec![
        ("brand", "Nike"),
        ("barcode", "0123456789012"),
    ]);
    let b = make_entity("sku2", vec![
        ("brand", "Nike"),
        ("barcode", "9876543210987"),
    ]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);
    assert_eq!(decision.decision, Decision::NoMerge);
    assert_eq!(decision.confidence, 0.0);
    assert!(
        decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Expected HARD_NEGATIVE marker, got {:?}",
        decision.matched_on
    );
    // No rules should have been evaluated
    assert!(decision.rule_results.is_empty());
}

#[test]
fn test_hard_negative_skips_missing_fields() {
    // One entity is missing the barcode field - should NOT reject (absence != evidence).
    let rules: Vec<Box<dyn cannon_core::matching::MatchRule>> = vec![
        Box::new(ExactMatch {
            field: "brand".to_string(),
            weight: 0.5,
            case_insensitive: true,
            normalize: true,
            normalizer: None,
            rule_name: "brand_exact".to_string(),
        }),
        Box::new(ExactMatch {
            field: "barcode".to_string(),
            weight: 1.0,
            case_insensitive: false,
            normalize: true,
            normalizer: Some("barcode".to_string()),
            rule_name: "barcode_exact".to_string(),
        }),
    ];
    let engine = ReconciliationEngine::new(rules, vec![], 0.8, 0.5);
    let resolver = OverrideResolver::new(vec![]);

    let a = make_entity("sku1", vec![
        ("brand", "Nike"),
        ("barcode", "0123456789012"),
    ]);
    let b = make_entity("sku2", vec![
        ("brand", "Nike"),
        // No barcode - missing field should not trigger hard negative
    ]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);
    // Should proceed to scoring, not be hard-rejected
    assert!(
        !decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Missing field should not trigger hard negative, got {:?}",
        decision.matched_on
    );
    // Rules should have been evaluated
    assert!(!decision.rule_results.is_empty());
}

#[test]
fn test_hard_negative_respects_normalization() {
    // Hard negative requires an active semantic engine (subtypes), so use a spec.
    // The cascade spec has mpn_exact with weight=0.9, case_insensitive=true.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    // Same MPN, different case - should NOT be hard-rejected (case_insensitive=true)
    // Both entities also need brand so they qualify as product_model subtype.
    let a = make_entity("sku1", vec![("brand", "acme"), ("mpn", "ABC-123")]);
    let b = make_entity("sku2", vec![("brand", "acme"), ("mpn", "abc-123")]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);
    assert!(
        !decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Case-insensitive match should not trigger hard negative, got {:?}",
        decision.matched_on
    );

    // Different MPNs should be hard-rejected
    let c = make_entity("sku3", vec![("brand", "acme"), ("mpn", "XYZ-999")]);
    let decision2 = engine.evaluate_pair(&a, &c, &resolver, 1.0);
    assert!(
        decision2.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Different MPNs should trigger hard negative, got {:?}",
        decision2.matched_on
    );
}

#[test]
fn test_hard_negative_skips_low_weight_fields() {
    // A field with weight < 0.9 should NOT be used for hard negative evidence.
    let rules: Vec<Box<dyn cannon_core::matching::MatchRule>> = vec![
        Box::new(ExactMatch {
            field: "brand".to_string(),
            weight: 0.5, // Below 0.9 threshold
            case_insensitive: true,
            normalize: true,
            normalizer: None,
            rule_name: "brand_exact".to_string(),
        }),
    ];
    let engine = ReconciliationEngine::new(rules, vec![], 0.8, 0.5);
    let resolver = OverrideResolver::new(vec![]);

    // Different brands but weight too low for hard negative
    let a = make_entity("sku1", vec![("brand", "Nike")]);
    let b = make_entity("sku2", vec![("brand", "Adidas")]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);
    assert!(
        !decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Low-weight field should not trigger hard negative, got {:?}",
        decision.matched_on
    );
    // Should have gone through normal scoring
    assert!(!decision.rule_results.is_empty());
}

// ============================================================================
// Layer 2+3: Full cascade with semantic constraints
// ============================================================================

/// Spec with subtypes, constraints, AND high-weight exact fields (barcode/mpn).
fn cascade_spec_yaml() -> &'static str {
    r#"
api_version: kanoniv/v2
identity_version: product_v1
entity:
  name: product
  subtypes:
    - name: sellable_sku
      priority: 10
      conditions:
        - field: barcode
          operator: present
        - field: mpn
          operator: present
    - name: product_variant
      priority: 5
      conditions:
        - field: color
          operator: present
        - field: brand
          operator: present
    - name: product_model
      priority: 1
      conditions:
        - field: brand
          operator: present
  constraints:
    - subtypes: [product_model, product_variant]
      action: relate
      link_type: variant_of
      direction: b_to_a
    - subtypes: [product_model, product_model]
      action: merge
    - subtypes: [product_variant, product_variant]
      action: merge
    - subtypes: [sellable_sku, sellable_sku]
      action: merge
  subtype_confidence_threshold: 0.7
sources:
  - name: catalog
    adapter: csv
    location: products.csv
    primary_key: id
    attributes:
      brand: brand
      product_name: product_name
      barcode: barcode
      mpn: mpn
      color: color
blocking:
  strategy: composite
  keys:
    - [brand]
rules:
  - name: brand
    type: exact
    field: brand
    weight: 0.5
  - name: product_name
    type: similarity
    field: product_name
    algorithm: jaro_winkler
    threshold: 0.8
    weight: 0.5
  - name: barcode_exact
    type: exact
    field: barcode
    weight: 1.0
    options:
      normalizer: barcode
  - name: mpn_exact
    type: exact
    field: mpn
    weight: 0.9
    options:
      case_insensitive: true
      normalizer: mpn
decision:
  thresholds:
    match: 0.7
    review: 0.4
"#
}

#[test]
fn test_full_cascade_cross_type_no_review() {
    // A model + variant pair with positive similarity. Before the cascade fix,
    // this might land in the review zone. With Layer 2 (semantic constraints),
    // it should be resolved as Relate - never Review.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    let model = make_entity("model1", vec![
        ("brand", "samsung"),
        ("product_name", "samsung galaxy s24"),
    ]);
    let variant = make_entity("variant1", vec![
        ("brand", "samsung"),
        ("color", "blue"),
        ("product_name", "samsung galaxy s24 blue"),
    ]);

    let decision = engine.evaluate_pair(&model, &variant, &resolver, 1.0);

    // Should be Relate (not Review)
    assert!(
        matches!(decision.resolution, ResolutionDecision::Relate { ref link_type, .. } if link_type == "variant_of"),
        "Cross-type pair should be Relate(variant_of), not Review. Got: {:?}",
        decision.resolution
    );
    // The decision field should NOT be Review
    assert_ne!(decision.decision, Decision::Review,
        "Cross-type pair resolved by constraint should not produce Review decision");
}

#[test]
fn test_full_cascade_same_type_review() {
    // Two product_model entities with ambiguous similarity. Since both are the
    // same subtype (model-model -> MergeAllowed), they should go through
    // statistical thresholding. If the score falls in the review zone, that's
    // a genuinely ambiguous case and Review is correct.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    // Two models with same brand but slightly different names
    let model_a = make_entity("model_a", vec![
        ("brand", "samsung"),
        ("product_name", "samsung galaxy s24"),
    ]);
    let model_b = make_entity("model_b", vec![
        ("brand", "samsung"),
        ("product_name", "samsung galaxy s23"),
    ]);

    let decision = engine.evaluate_pair(&model_a, &model_b, &resolver, 1.0);

    // This should go through statistical thresholding (same type, MergeAllowed constraint).
    // The exact decision depends on scores, but it should NOT be resolved by constraints.
    // Just verify the cascade didn't break standard same-type behavior.
    assert!(
        matches!(decision.resolution, ResolutionDecision::Merge | ResolutionDecision::Review | ResolutionDecision::NoMatch),
        "Same-type pair should go through statistical thresholding, got {:?}",
        decision.resolution
    );
}

#[test]
fn test_full_cascade_hard_negative_prevents_scoring() {
    // Two SKUs that share brand + similar name but have DIFFERENT barcodes.
    // Layer 1 should reject immediately before any scoring or constraint checking.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    let sku_a = make_entity("sku_a", vec![
        ("brand", "nike"),
        ("product_name", "nike air max 90"),
        ("barcode", "1234567890123"),
        ("mpn", "AM90-WHT"),
    ]);
    let sku_b = make_entity("sku_b", vec![
        ("brand", "nike"),
        ("product_name", "nike air max 90"),
        ("barcode", "9999999999999"),
        ("mpn", "AM90-BLK"),
    ]);

    let decision = engine.evaluate_pair(&sku_a, &sku_b, &resolver, 1.0);
    assert_eq!(decision.decision, Decision::NoMerge,
        "Different barcodes should produce immediate NoMerge");
    assert!(decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Should be tagged as hard negative");
    assert!(decision.rule_results.is_empty(),
        "No rules should be evaluated after hard negative");
}

#[test]
fn test_hard_negative_skips_empty_string_values() {
    // An entity with an empty string barcode should NOT trigger hard negative.
    // Empty string is equivalent to missing - it provides no evidence.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    let a = make_entity("sku1", vec![
        ("brand", "nike"),
        ("barcode", "1234567890123"),
    ]);
    let b = make_entity("sku2", vec![
        ("brand", "nike"),
        ("barcode", ""), // Empty string - should be treated as missing
    ]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);
    assert!(
        !decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Empty string should not trigger hard negative, got {:?}",
        decision.matched_on
    );
}

#[test]
fn test_hard_negative_inactive_without_subtypes() {
    // A person-style spec without subtypes should NOT fire hard negative,
    // even with high-weight exact fields. This protects against false rejections
    // where email/phone mismatch is not proof of non-match.
    let rules: Vec<Box<dyn cannon_core::matching::MatchRule>> = vec![
        Box::new(ExactMatch {
            field: "email".to_string(),
            weight: 1.0,
            case_insensitive: true,
            normalize: true,
            normalizer: Some("email".to_string()),
            rule_name: "email_exact".to_string(),
        }),
    ];
    // No subtypes -> ReconciliationEngine::new creates an inactive semantic engine
    let engine = ReconciliationEngine::new(rules, vec![], 0.8, 0.5);
    let resolver = OverrideResolver::new(vec![]);

    let a = make_entity("person1", vec![("email", "alice@example.com")]);
    let b = make_entity("person2", vec![("email", "bob@example.com")]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);
    // Should NOT hard-reject: no semantic engine means no hard negative
    assert!(
        !decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Without subtypes, hard negative should not fire. Got {:?}",
        decision.matched_on
    );
    // Rules should have been evaluated normally
    assert!(!decision.rule_results.is_empty(),
        "Rules should be evaluated when hard negative is inactive");
}

#[test]
fn test_full_cascade_same_barcode_merges() {
    // Two SKUs with IDENTICAL barcodes should merge, not be hard-rejected.
    // This verifies that matching values pass through Layer 1 correctly
    // and get tagged as hard positive evidence.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    let sku_a = make_entity("sku_a", vec![
        ("brand", "nike"),
        ("product_name", "nike air max 90 white"),
        ("barcode", "1234567890123"),
        ("mpn", "AM90-WHT"),
    ]);
    let sku_b = make_entity("sku_b", vec![
        ("brand", "nike"),
        ("product_name", "nike air max 90 white"),
        ("barcode", "1234567890123"),
        ("mpn", "AM90-WHT"),
    ]);

    let decision = engine.evaluate_pair(&sku_a, &sku_b, &resolver, 1.0);
    // Should NOT be hard-rejected (identical barcodes)
    assert!(
        !decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Identical barcodes should not trigger hard negative, got {:?}",
        decision.matched_on
    );
    // Should merge (identical data)
    assert_eq!(decision.decision, Decision::Merge,
        "Identical SKUs should produce Merge decision");
    // Should be tagged as hard positive
    assert!(
        decision.matched_on.iter().any(|m| m.starts_with("HARD_POSITIVE:")),
        "Identical barcodes should be tagged as HARD_POSITIVE, got {:?}",
        decision.matched_on
    );
}

#[test]
fn test_hard_positive_overrides_review_threshold() {
    // THE KEY SCENARIO: Two products share a barcode but have different brand/model
    // names. Without hard positive, the FS score would be borderline (~0.74) and
    // produce a Review. With hard positive, the matching barcode forces Merge.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    // Same barcode, but different brand and product_name (cross-source naming)
    let a = make_entity("pim-1", vec![
        ("brand", "Samsung Electronics"),
        ("product_name", "Galaxy S24 Ultra - Phantom Black 256GB"),
        ("barcode", "8806095368214"),
    ]);
    let b = make_entity("ecom-1", vec![
        ("brand", "SAMSUNG"),
        ("product_name", "Samsung Galaxy S24U 256G Black"),
        ("barcode", "8806095368214"),
    ]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);

    // Hard positive should force Merge (not Review)
    assert_eq!(decision.decision, Decision::Merge,
        "Same barcode should force Merge via hard positive, got {:?}", decision.decision);
    assert!(
        decision.matched_on.iter().any(|m| m.starts_with("HARD_POSITIVE:")),
        "Should be tagged as HARD_POSITIVE, got {:?}", decision.matched_on
    );
    // Rules should still be evaluated (hard positive doesn't skip scoring)
    assert!(!decision.rule_results.is_empty(),
        "Rules should still be evaluated for telemetry");
}

#[test]
fn test_hard_positive_inactive_without_subtypes() {
    // Like hard negative, hard positive should NOT fire without subtypes.
    // Person specs with matching emails should go through normal scoring.
    let rules: Vec<Box<dyn cannon_core::matching::MatchRule>> = vec![
        Box::new(ExactMatch {
            field: "email".to_string(),
            weight: 1.0,
            case_insensitive: true,
            normalize: true,
            normalizer: Some("email".to_string()),
            rule_name: "email_exact".to_string(),
        }),
    ];
    let engine = ReconciliationEngine::new(rules, vec![], 0.8, 0.5);
    let resolver = OverrideResolver::new(vec![]);

    let a = make_entity("p1", vec![("email", "alice@example.com")]);
    let b = make_entity("p2", vec![("email", "alice@example.com")]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);
    // Should NOT have hard positive (no semantic engine)
    assert!(
        !decision.matched_on.iter().any(|m| m.starts_with("HARD_POSITIVE:")),
        "Without subtypes, hard positive should not fire. Got {:?}", decision.matched_on
    );
}

#[test]
fn test_full_cascade_first_hard_negative_field_wins() {
    // When multiple high-weight fields exist and the first one mismatches,
    // the hard negative should fire on that first field.
    let spec: IdentitySpec = serde_yaml::from_str(cascade_spec_yaml()).unwrap();
    let plan = SpecCompiler::compile(&spec).unwrap();
    let engine = ReconciliationEngine::from_plan(&plan);
    let resolver = OverrideResolver::new(vec![]);

    // Different barcode AND different MPN - should reject on whichever field
    // the engine encounters first.
    let a = make_entity("sku_a", vec![
        ("brand", "nike"),
        ("barcode", "1111111111111"),
        ("mpn", "AAA-111"),
    ]);
    let b = make_entity("sku_b", vec![
        ("brand", "nike"),
        ("barcode", "2222222222222"),
        ("mpn", "BBB-222"),
    ]);

    let decision = engine.evaluate_pair(&a, &b, &resolver, 1.0);
    assert_eq!(decision.decision, Decision::NoMerge);
    assert!(decision.matched_on.iter().any(|m| m.starts_with("HARD_NEGATIVE:")),
        "Should have hard negative marker");
    // Only one hard negative marker (early return on first mismatch)
    let hard_neg_count = decision.matched_on.iter()
        .filter(|m| m.starts_with("HARD_NEGATIVE:"))
        .count();
    assert_eq!(hard_neg_count, 1,
        "Should have exactly one hard negative marker (first mismatch), got {}",
        hard_neg_count);
}
