use super::types::{NormalizedEntity, RuleResult};
use super::normalization::*;
use strsim::{jaro_winkler, normalized_levenshtein};
use rphonetic::{Soundex, DoubleMetaphone, Encoder};
use std::collections::{HashMap, HashSet};

pub trait MatchRule: Send + Sync {
    fn name(&self) -> &str;
    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult;

    /// Returns (field, weight, case_insensitive, normalize_trim, normalizer) if this is an
    /// exact-match rule. Used by the hard negative evidence layer to identify distinguishing
    /// fields - if both entities have non-empty values for a high-weight exact field and the
    /// values differ, the pair is immediately rejected without scoring.
    fn exact_field_info(&self) -> Option<(&str, f64, bool, bool, &Option<String>)> { None }
}

pub fn apply_normalizer(name: &str, value: &str) -> String {
    let result = match name {
        "phone" => PhoneNormalizer.normalize(value),
        "email" => EmailNormalizer.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_string(),
    }
}

pub struct ExactMatch {
    pub field: String,
    pub weight: f64,
    pub case_insensitive: bool,
    pub normalize: bool,
    pub normalizer: Option<String>,
    pub rule_name: String,
}

impl MatchRule for ExactMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn exact_field_info(&self) -> Option<(&str, f64, bool, bool, &Option<String>)> {
        Some((&self.field, self.weight, self.case_insensitive, self.normalize, &self.normalizer))
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        // Empty string = no data, same as absent field
        let val_a = a.data.get(&self.field).map(|v| v.as_str()).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).map(|v| v.as_str()).filter(|v| !v.is_empty());

        // Field absent or empty = no evidence; don't count in denominator
        let data_present = val_a.is_some() && val_b.is_some();

        let matched = match (val_a, val_b) {
            (Some(a), Some(b)) => {
                let mut s_a = a.to_string();
                let mut s_b = b.to_string();

                if let Some(ref n) = self.normalizer {
                    s_a = apply_normalizer(n, &s_a);
                    s_b = apply_normalizer(n, &s_b);
                }

                if self.normalize {
                    s_a = s_a.trim().to_string();
                    s_b = s_b.trim().to_string();
                }

                if self.case_insensitive {
                    s_a = s_a.to_lowercase();
                    s_b = s_b.to_lowercase();
                }

                s_a == s_b
            },
            _ => false,
        };

        let effective_weight = if data_present { self.weight } else { 0.0 };

        RuleResult {
            rule_name: self.rule_name.clone(),
            score: if matched { 1.0 } else { 0.0 },
            weight: effective_weight,
            matched,
            explanation: if matched {
                format!("Exact match on field {}", self.field)
            } else if !data_present {
                format!("Missing data on field {}", self.field)
            } else {
                format!("No match on field {}", self.field)
            },
        }
    }
}

pub struct RangeMatch {
    pub field: String,
    pub tolerance: f64,
    pub weight: f64,
    pub normalizer: Option<String>,
    pub rule_name: String,
}

impl MatchRule for RangeMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        fn parse_f64(s: &str) -> Option<f64> {
            s.parse::<f64>().ok()
        }

        let val_a = a.data.get(&self.field).and_then(|s| parse_f64(s));
        let val_b = b.data.get(&self.field).and_then(|s| parse_f64(s));

        let data_present = val_a.is_some() && val_b.is_some();

        let (matched, score) = match (val_a, val_b) {
            (Some(a), Some(b)) => {
                let diff = (a - b).abs();
                if diff <= self.tolerance {
                    (true, 1.0)
                } else {
                    (false, 0.0)
                }
            },
            _ => (false, 0.0),
        };

        let effective_weight = if data_present { self.weight } else { 0.0 };

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: effective_weight,
            matched,
            explanation: if matched {
                format!("Range match on field {} (diff within {})", self.field, self.tolerance)
            } else if !data_present {
                format!("Missing data on field {}", self.field)
            } else {
                format!("Range mismatch on field {}", self.field)
            },
        }
    }
}

pub struct MlMatch {
    pub model_id: String,
    pub features: Vec<String>,
    pub threshold: f64,
    pub weight: f64,
    pub rule_name: String,
}

impl MatchRule for MlMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, _a: &NormalizedEntity, _b: &NormalizedEntity) -> RuleResult {
         RuleResult {
            rule_name: self.rule_name.clone(),
            score: 0.0,
            weight: self.weight,
            matched: false,
            explanation: "ML matching not fully implemented currently".to_string(),
        }
    }
}

pub struct FuzzyStringMatch {
    pub field: String,
    pub threshold: f64,
    pub weight: f64,
    pub normalizer: Option<String>,
    pub rule_name: String,
}

impl MatchRule for FuzzyStringMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let val_a = a.data.get(&self.field).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).filter(|v| !v.is_empty());

        let data_present = val_a.is_some() && val_b.is_some();

        let score = match (val_a, val_b) {
            (Some(a), Some(b)) => {
                let (a_str, b_str) = if let Some(ref n) = self.normalizer {
                    (apply_normalizer(n, a), apply_normalizer(n, b))
                } else {
                    (a.to_lowercase(), b.to_lowercase())
                };
                jaro_winkler(&a_str, &b_str)
            }
            _ => 0.0,
        };

        let matched = score >= self.threshold;
        let effective_weight = if data_present { self.weight } else { 0.0 };

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: effective_weight,
            matched,
            explanation: if matched {
                format!("Fuzzy match on field {} (score: {:.2})", self.field, score)
            } else if !data_present {
                format!("Missing data on field {}", self.field)
            } else {
                format!("Insufficient fuzzy score on field {} (score: {:.2})", self.field, score)
            },
        }
    }
}

pub struct LevenshteinMatch {
    pub field: String,
    pub threshold: f64,
    pub weight: f64,
    pub normalizer: Option<String>,
    pub rule_name: String,
}

impl MatchRule for LevenshteinMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let val_a = a.data.get(&self.field).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).filter(|v| !v.is_empty());

        let data_present = val_a.is_some() && val_b.is_some();

        let score = match (val_a, val_b) {
            (Some(a), Some(b)) => {
                let (a_str, b_str) = if let Some(ref n) = self.normalizer {
                    (apply_normalizer(n, a), apply_normalizer(n, b))
                } else {
                    (a.to_lowercase(), b.to_lowercase())
                };
                normalized_levenshtein(&a_str, &b_str)
            }
            _ => 0.0,
        };

        let matched = score >= self.threshold;
        let effective_weight = if data_present { self.weight } else { 0.0 };

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: effective_weight,
            matched,
            explanation: if matched {
                format!("Levenshtein match on field {} (score: {:.2})", self.field, score)
            } else if !data_present {
                format!("Missing data on field {}", self.field)
            } else {
                format!("Insufficient Levenshtein score on field {} (score: {:.2})", self.field, score)
            },
        }
    }
}

pub struct DomainMatch {
    pub fields: Vec<String>,
    pub weight: f64,
    pub rule_name: String,
}

impl MatchRule for DomainMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        fn extract_domain(s: &str) -> Option<String> {
            if let Some(at_pos) = s.rfind('@') {
                Some(s[at_pos + 1..].to_lowercase())
            } else if s.contains('.') {
                let s = s.strip_prefix("https://").or_else(|| s.strip_prefix("http://")).unwrap_or(s);
                let s = s.strip_prefix("www.").unwrap_or(s);
                Some(s.split('/').next().unwrap_or(s).to_lowercase())
            } else {
                None
            }
        }

        // Collect all domains from both entities across all configured fields
        let domains_a: Vec<String> = self.fields.iter()
            .filter_map(|f| a.data.get(f))
            .filter_map(|v| extract_domain(v))
            .collect();
        let domains_b: Vec<String> = self.fields.iter()
            .filter_map(|f| b.data.get(f))
            .filter_map(|v| extract_domain(v))
            .collect();

        // Check if any domain from A matches any domain from B (cross-field)
        for da in &domains_a {
            for db in &domains_b {
                if da == db {
                    return RuleResult {
                        rule_name: self.rule_name.clone(),
                        score: 1.0,
                        weight: self.weight,
                        matched: true,
                        explanation: format!("Domain match ({})", da),
                    };
                }
            }
        }

        RuleResult {
            rule_name: self.rule_name.clone(),
            score: 0.0,
            weight: self.weight,
            matched: false,
            explanation: "No domain match found".to_string(),
        }
    }
}

pub struct CompositeMatch {
    pub rules: Vec<Box<dyn MatchRule>>,
    pub weight: f64,
    pub require_all: bool,
    pub rule_name: String,
}

impl MatchRule for CompositeMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let results: Vec<RuleResult> = self.rules.iter().map(|r| r.evaluate(a, b)).collect();

        let matched = if self.require_all {
            results.iter().all(|r| r.matched)
        } else {
            results.iter().any(|r| r.matched)
        };

        let score = if results.is_empty() {
            0.0
        } else {
            results.iter().map(|r| r.score).sum::<f64>() / results.len() as f64
        };

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: self.weight,
            matched,
            explanation: format!(
                "Composite match ({} component rules, matched: {})",
                results.len(),
                matched
            ),
        }
    }
}

// --- Soundex phonetic matching ---

pub struct SoundexMatch {
    pub field: String,
    pub threshold: f64,
    pub weight: f64,
    pub rule_name: String,
}

impl MatchRule for SoundexMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let val_a = a.data.get(&self.field).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).filter(|v| !v.is_empty());

        let score = match (val_a, val_b) {
            (Some(a_str), Some(b_str)) => {
                let encoder = Soundex::default();
                let code_a = encoder.encode(a_str);
                let code_b = encoder.encode(b_str);
                if code_a == code_b { 1.0 } else { 0.0 }
            }
            _ => 0.0,
        };

        let matched = score >= self.threshold;

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: self.weight,
            matched,
            explanation: if matched {
                format!("Soundex match on field {} (codes match)", self.field)
            } else {
                format!("Soundex mismatch on field {} (codes differ)", self.field)
            },
        }
    }
}

// --- Double Metaphone phonetic matching ---

pub struct MetaphoneMatch {
    pub field: String,
    pub threshold: f64,
    pub weight: f64,
    pub rule_name: String,
}

impl MatchRule for MetaphoneMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let val_a = a.data.get(&self.field).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).filter(|v| !v.is_empty());

        let score = match (val_a, val_b) {
            (Some(a_str), Some(b_str)) => {
                let encoder = DoubleMetaphone::default();
                let primary_a = encoder.encode(a_str);
                let primary_b = encoder.encode(b_str);

                if primary_a == primary_b {
                    // Primary codes match: full score
                    1.0
                } else {
                    // Check alternate codes for partial match
                    let alt_a = encoder.encode_alternate(a_str);
                    let alt_b = encoder.encode_alternate(b_str);

                    if primary_a == alt_b || alt_a == primary_b {
                        0.5
                    } else {
                        0.0
                    }
                }
            }
            _ => 0.0,
        };

        let matched = score >= self.threshold;

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: self.weight,
            matched,
            explanation: if matched {
                format!("Metaphone match on field {} (score: {:.2})", self.field, score)
            } else {
                format!("Metaphone mismatch on field {} (score: {:.2})", self.field, score)
            },
        }
    }
}

// --- Cosine similarity (character bigram) ---

pub struct CosineMatch {
    pub field: String,
    pub threshold: f64,
    pub weight: f64,
    pub rule_name: String,
}

impl CosineMatch {
    /// Build a bag-of-bigrams frequency map from a string.
    fn bigram_vector(s: &str) -> HashMap<(char, char), f64> {
        let chars: Vec<char> = s.chars().collect();
        let mut freq: HashMap<(char, char), f64> = HashMap::new();
        if chars.len() >= 2 {
            for window in chars.windows(2) {
                *freq.entry((window[0], window[1])).or_insert(0.0) += 1.0;
            }
        }
        freq
    }

    /// Compute cosine similarity between two bigram frequency maps.
    fn cosine_similarity(a: &HashMap<(char, char), f64>, b: &HashMap<(char, char), f64>) -> f64 {
        if a.is_empty() || b.is_empty() {
            return 0.0;
        }

        let mut dot = 0.0;
        let mut norm_a = 0.0;
        let mut norm_b = 0.0;

        for (key, &va) in a {
            norm_a += va * va;
            if let Some(&vb) = b.get(key) {
                dot += va * vb;
            }
        }

        for &vb in b.values() {
            norm_b += vb * vb;
        }

        if norm_a == 0.0 || norm_b == 0.0 {
            return 0.0;
        }

        dot / (norm_a.sqrt() * norm_b.sqrt())
    }
}

impl MatchRule for CosineMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let val_a = a.data.get(&self.field).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).filter(|v| !v.is_empty());

        let score = match (val_a, val_b) {
            (Some(a_str), Some(b_str)) => {
                let a_lower = a_str.to_lowercase();
                let b_lower = b_str.to_lowercase();
                let vec_a = Self::bigram_vector(&a_lower);
                let vec_b = Self::bigram_vector(&b_lower);
                Self::cosine_similarity(&vec_a, &vec_b)
            }
            _ => 0.0,
        };

        let matched = score >= self.threshold;

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: self.weight,
            matched,
            explanation: if matched {
                format!("Cosine match on field {} (score: {:.2})", self.field, score)
            } else {
                format!("Insufficient cosine score on field {} (score: {:.2})", self.field, score)
            },
        }
    }
}

// --- Haversine geographic distance matching ---

pub struct HaversineMatch {
    pub field: String,
    pub threshold: f64, // tolerance in km
    pub weight: f64,
    pub rule_name: String,
}

impl HaversineMatch {
    const EARTH_RADIUS_KM: f64 = 6371.0;

    /// Parse a "lat,lon" string into (latitude, longitude) in degrees.
    fn parse_latlon(s: &str) -> Option<(f64, f64)> {
        let parts: Vec<&str> = s.split(',').collect();
        if parts.len() == 2 {
            let lat = parts[0].trim().parse::<f64>().ok()?;
            let lon = parts[1].trim().parse::<f64>().ok()?;
            if (-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon) {
                Some((lat, lon))
            } else {
                None
            }
        } else {
            None
        }
    }

    /// Compute the haversine distance (in km) between two points.
    fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
        let lat1_rad = lat1.to_radians();
        let lat2_rad = lat2.to_radians();
        let dlat = (lat2 - lat1).to_radians();
        let dlon = (lon2 - lon1).to_radians();

        let a = (dlat / 2.0).sin().powi(2)
            + lat1_rad.cos() * lat2_rad.cos() * (dlon / 2.0).sin().powi(2);
        let c = 2.0 * a.sqrt().asin();

        Self::EARTH_RADIUS_KM * c
    }
}

impl MatchRule for HaversineMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let val_a = a.data.get(&self.field).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).filter(|v| !v.is_empty());

        let (score, distance_info) = match (val_a, val_b) {
            (Some(a_str), Some(b_str)) => {
                match (Self::parse_latlon(a_str), Self::parse_latlon(b_str)) {
                    (Some((lat1, lon1)), Some((lat2, lon2))) => {
                        let dist = Self::haversine_distance(lat1, lon1, lat2, lon2);
                        let tolerance = self.threshold;
                        let s = if dist <= tolerance {
                            1.0
                        } else if dist <= 2.0 * tolerance {
                            // Linear scale from 1.0 at tolerance to 0.0 at 2x tolerance
                            1.0 - (dist - tolerance) / tolerance
                        } else {
                            0.0
                        };
                        (s, Some(dist))
                    }
                    _ => (0.0, None),
                }
            }
            _ => (0.0, None),
        };

        // For haversine, matched means distance <= tolerance (score == 1.0)
        let matched = match distance_info {
            Some(dist) => dist <= self.threshold,
            None => false,
        };

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: self.weight,
            matched,
            explanation: match distance_info {
                Some(dist) => {
                    if matched {
                        format!("Haversine match on field {} (distance: {:.2} km, within {:.2} km tolerance)", self.field, dist, self.threshold)
                    } else {
                        format!("Haversine mismatch on field {} (distance: {:.2} km, exceeds {:.2} km tolerance)", self.field, dist, self.threshold)
                    }
                }
                None => format!("Haversine: could not parse coordinates from field {}", self.field),
            },
        }
    }
}

// ---------------------------------------------------------------------------
// Product-specific matching rules
// ---------------------------------------------------------------------------

/// Token overlap (Jaccard similarity) on tokenized strings.
/// Useful for product names where word order varies:
/// "Samsung 55in QLED 4K TV" vs "Samsung QLED 4K 55in Smart TV"
pub struct TokenOverlapMatch {
    pub field: String,
    pub threshold: f64,
    pub weight: f64,
    pub normalizer: Option<String>,
    pub rule_name: String,
}

impl TokenOverlapMatch {
    /// Tokenize a string: lowercase, split on whitespace and common separators,
    /// filter empty tokens, collect into a set.
    fn tokenize(s: &str) -> HashSet<String> {
        s.to_lowercase()
            .split(|c: char| c.is_whitespace() || c == '-' || c == '/' || c == '|' || c == ',')
            .filter(|t| !t.is_empty())
            .map(|t| t.to_string())
            .collect()
    }

    /// Jaccard similarity: |A intersection B| / |A union B|
    fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
        if a.is_empty() && b.is_empty() {
            return 0.0;
        }
        let intersection = a.intersection(b).count() as f64;
        let union = a.union(b).count() as f64;
        if union == 0.0 { 0.0 } else { intersection / union }
    }
}

impl MatchRule for TokenOverlapMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let val_a = a.data.get(&self.field).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).filter(|v| !v.is_empty());

        let data_present = val_a.is_some() && val_b.is_some();

        let score = match (val_a, val_b) {
            (Some(a_str), Some(b_str)) => {
                let (a_norm, b_norm) = if let Some(ref n) = self.normalizer {
                    (apply_normalizer(n, a_str), apply_normalizer(n, b_str))
                } else {
                    (a_str.to_lowercase(), b_str.to_lowercase())
                };
                let tokens_a = Self::tokenize(&a_norm);
                let tokens_b = Self::tokenize(&b_norm);
                Self::jaccard(&tokens_a, &tokens_b)
            }
            _ => 0.0,
        };

        let matched = score >= self.threshold;
        let effective_weight = if data_present { self.weight } else { 0.0 };

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: effective_weight,
            matched,
            explanation: if matched {
                format!("Token overlap match on field {} (Jaccard: {:.2})", self.field, score)
            } else if !data_present {
                format!("Missing data on field {}", self.field)
            } else {
                format!("Insufficient token overlap on field {} (Jaccard: {:.2})", self.field, score)
            },
        }
    }
}

/// Numeric alignment matching: extract numeric tokens with optional units,
/// normalize units to base, and score by fraction of matching numeric-unit pairs.
/// E.g. "256GB 6.1in" vs "256GB 6.1-inch" -> 1.0 (both pairs match).
pub struct NumericAlignmentMatch {
    pub field: String,
    pub threshold: f64,
    pub weight: f64,
    pub rule_name: String,
}

impl NumericAlignmentMatch {
    /// Extract numeric values with optional units from a string.
    /// Returns Vec<(normalized_value_in_base_unit, unit_category)>.
    fn extract_numerics(s: &str) -> Vec<(f64, &'static str)> {
        let mut results = Vec::new();
        let lowered = s.to_lowercase();
        let chars: Vec<char> = lowered.chars().collect();
        let len = chars.len();
        let mut i = 0;

        while i < len {
            // Find start of a number
            if chars[i].is_ascii_digit() || (chars[i] == '.' && i + 1 < len && chars[i + 1].is_ascii_digit()) {
                let start = i;
                // Consume digits and at most one decimal point
                let mut has_dot = chars[i] == '.';
                i += 1;
                while i < len && (chars[i].is_ascii_digit() || (chars[i] == '.' && !has_dot)) {
                    if chars[i] == '.' { has_dot = true; }
                    i += 1;
                }
                let num_str: String = chars[start..i].iter().collect();
                if let Ok(num) = num_str.parse::<f64>() {
                    // Try to consume a unit suffix
                    let unit_start = i;
                    while i < len && chars[i].is_ascii_alphabetic() {
                        i += 1;
                    }
                    let unit: String = chars[unit_start..i].iter().collect();
                    let (normalized, category) = Self::normalize_unit(num, &unit);
                    results.push((normalized, category));
                }
            } else {
                i += 1;
            }
        }
        results
    }

    /// Normalize a numeric value with unit to a base unit.
    /// Returns (value_in_base_unit, unit_category).
    fn normalize_unit(value: f64, unit: &str) -> (f64, &'static str) {
        match unit {
            // Storage
            "tb" | "terabyte" | "terabytes" => (value * 1024.0, "storage_gb"),
            "gb" | "gigabyte" | "gigabytes" => (value, "storage_gb"),
            "mb" | "megabyte" | "megabytes" => (value / 1024.0, "storage_gb"),
            "kb" | "kilobyte" | "kilobytes" => (value / (1024.0 * 1024.0), "storage_gb"),
            // Length - no single-letter "m" (conflicts with model numbers like "Model 5M")
            "meter" | "meters" | "metre" | "metres" => (value * 100.0, "length_cm"),
            "cm" | "centimeter" | "centimeters" => (value, "length_cm"),
            "mm" | "millimeter" | "millimeters" => (value / 10.0, "length_cm"),
            "in" | "inch" | "inches" => (value * 2.54, "length_cm"),
            "ft" | "foot" | "feet" => (value * 30.48, "length_cm"),
            // Weight - no single-letter "g" (conflicts with product grades like "Class G")
            "kg" | "kilogram" | "kilograms" => (value * 1000.0, "weight_g"),
            "gram" | "grams" => (value, "weight_g"),
            "lb" | "lbs" | "pound" | "pounds" => (value * 453.592, "weight_g"),
            "oz" | "ounce" | "ounces" => (value * 28.3495, "weight_g"),
            // Volume - no single-letter "l" (conflicts with size suffixes like "Size L")
            "liter" | "liters" | "litre" | "litres" => (value * 1000.0, "volume_ml"),
            "ml" | "milliliter" | "milliliters" => (value, "volume_ml"),
            "gal" | "gallon" | "gallons" => (value * 3785.41, "volume_ml"),
            "floz" => (value * 29.5735, "volume_ml"),
            // Electrical - no single-letter "v"/"w"/"a" (conflict with product identifiers)
            "volt" | "volts" => (value, "voltage"),
            "watt" | "watts" => (value, "wattage"),
            "amp" | "amps" | "ampere" => (value, "amperage"),
            "mah" => (value, "battery_mah"),
            // Frequency
            "hz" | "hertz" => (value, "frequency_hz"),
            "khz" => (value * 1000.0, "frequency_hz"),
            "mhz" => (value * 1_000_000.0, "frequency_hz"),
            "ghz" => (value * 1_000_000_000.0, "frequency_hz"),
            // No unit or unrecognized - treat as dimensionless
            "" => (value, "dimensionless"),
            _ => (value, "unknown"),
        }
    }

    /// Score: fraction of numeric-unit pairs that match between A and B.
    /// Matching means same unit category and value within 0.5% tolerance.
    fn alignment_score(a: &[(f64, &str)], b: &[(f64, &str)]) -> f64 {
        if a.is_empty() && b.is_empty() {
            return 0.0;
        }
        if a.is_empty() || b.is_empty() {
            return 0.0;
        }

        let total = a.len().max(b.len());
        let mut matched = 0;
        let mut used_b: Vec<bool> = vec![false; b.len()];

        for (val_a, cat_a) in a {
            for (j, (val_b, cat_b)) in b.iter().enumerate() {
                if used_b[j] {
                    continue;
                }
                if cat_a == cat_b {
                    let tolerance = val_a.abs().max(val_b.abs()) * 0.005; // 0.5%
                    if (val_a - val_b).abs() <= tolerance.max(0.01) {
                        matched += 1;
                        used_b[j] = true;
                        break;
                    }
                }
            }
        }

        matched as f64 / total as f64
    }
}

impl MatchRule for NumericAlignmentMatch {
    fn name(&self) -> &str {
        &self.rule_name
    }

    fn evaluate(&self, a: &NormalizedEntity, b: &NormalizedEntity) -> RuleResult {
        let val_a = a.data.get(&self.field).filter(|v| !v.is_empty());
        let val_b = b.data.get(&self.field).filter(|v| !v.is_empty());

        let data_present = val_a.is_some() && val_b.is_some();

        let score = match (val_a, val_b) {
            (Some(a_str), Some(b_str)) => {
                let nums_a = Self::extract_numerics(a_str);
                let nums_b = Self::extract_numerics(b_str);
                Self::alignment_score(&nums_a, &nums_b)
            }
            _ => 0.0,
        };

        let matched = score >= self.threshold;
        let effective_weight = if data_present { self.weight } else { 0.0 };

        RuleResult {
            rule_name: self.rule_name.clone(),
            score,
            weight: effective_weight,
            matched,
            explanation: if matched {
                format!("Numeric alignment match on field {} (score: {:.2})", self.field, score)
            } else if !data_present {
                format!("Missing data on field {}", self.field)
            } else {
                format!("Insufficient numeric alignment on field {} (score: {:.2})", self.field, score)
            },
        }
    }
}

// ===========================================================================
// Tests
// ===========================================================================

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

    fn make_entity(ext_id: &str, data: Vec<(&str, &str)>) -> NormalizedEntity {
        let mut map = HashMap::new();
        for (k, v) in data {
            map.insert(k.to_string(), v.to_string());
        }
        NormalizedEntity {
            id: uuid::Uuid::new_v4(),
            tenant_id: uuid::Uuid::nil(),
            external_id: ext_id.to_string(),
            entity_type: "product".to_string(),
            data: map,
            source_name: "test".to_string(),
            valid_from: None,
            valid_to: None,
            last_updated: Utc::now(),
        }
    }

    // -- apply_normalizer dispatch tests --

    #[test]
    fn apply_normalizer_barcode_fields() {
        // "barcode", "gtin", "upc", "ean" should all route to BarcodeNormalizer
        let ean13 = apply_normalizer("barcode", "4006381333931");
        assert_eq!(ean13, "4006381333931");
        let gtin = apply_normalizer("gtin", "4-006381-333931");
        assert_eq!(gtin, "4006381333931");
        let upc = apply_normalizer("upc", "036000291452");
        assert_eq!(upc, "0036000291452");
        let ean = apply_normalizer("ean", "4006381333931");
        assert_eq!(ean, "4006381333931");
    }

    #[test]
    fn apply_normalizer_mpn_fields() {
        assert_eq!(apply_normalizer("mpn", "MK-7500-B"), "MK7500B");
        assert_eq!(apply_normalizer("part_number", "mk_7500_b"), "MK7500B");
    }

    #[test]
    fn apply_normalizer_brand() {
        assert_eq!(apply_normalizer("brand", "Apple Inc."), "apple");
    }

    #[test]
    fn apply_normalizer_invalid_returns_original() {
        // Invalid barcode should return original value (not panic)
        let result = apply_normalizer("barcode", "not-a-barcode");
        assert_eq!(result, "not-a-barcode");
    }

    // -- TokenOverlapMatch tests --

    #[test]
    fn token_overlap_identical_strings() {
        let rule = TokenOverlapMatch {
            field: "product_name".to_string(),
            threshold: 0.5,
            weight: 1.0,
            normalizer: None,
            rule_name: "token_overlap_product_name".to_string(),
        };
        let a = make_entity("p1", vec![("product_name", "Samsung 55in QLED 4K TV")]);
        let b = make_entity("p2", vec![("product_name", "Samsung 55in QLED 4K TV")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched);
        assert!((result.score - 1.0).abs() < 0.001, "Identical strings should have Jaccard 1.0");
    }

    #[test]
    fn token_overlap_reordered_tokens() {
        let rule = TokenOverlapMatch {
            field: "product_name".to_string(),
            threshold: 0.5,
            weight: 1.0,
            normalizer: None,
            rule_name: "token_overlap_product_name".to_string(),
        };
        let a = make_entity("p1", vec![("product_name", "Samsung 55in QLED 4K TV")]);
        let b = make_entity("p2", vec![("product_name", "Samsung QLED 4K 55in TV")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched, "Reordered tokens should match");
        assert!((result.score - 1.0).abs() < 0.001, "Same tokens in different order should have Jaccard 1.0");
    }

    #[test]
    fn token_overlap_partial_match() {
        let rule = TokenOverlapMatch {
            field: "product_name".to_string(),
            threshold: 0.5,
            weight: 1.0,
            normalizer: None,
            rule_name: "token_overlap_product_name".to_string(),
        };
        let a = make_entity("p1", vec![("product_name", "Samsung 55in QLED 4K Smart TV")]);
        let b = make_entity("p2", vec![("product_name", "Samsung QLED 4K 55in TV")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched, "Partial overlap above threshold should match");
        // A has 6 tokens: samsung, 55in, qled, 4k, smart, tv
        // B has 5 tokens: samsung, qled, 4k, 55in, tv
        // Intersection: 5 (samsung, 55in, qled, 4k, tv), Union: 6
        // Jaccard: 5/6 = 0.833
        assert!(result.score > 0.8, "Score should be > 0.8, got {}", result.score);
    }

    #[test]
    fn token_overlap_below_threshold() {
        let rule = TokenOverlapMatch {
            field: "product_name".to_string(),
            threshold: 0.8,
            weight: 1.0,
            normalizer: None,
            rule_name: "token_overlap_product_name".to_string(),
        };
        let a = make_entity("p1", vec![("product_name", "Samsung Galaxy S24 Ultra")]);
        let b = make_entity("p2", vec![("product_name", "Apple iPhone 15 Pro Max")]);
        let result = rule.evaluate(&a, &b);
        assert!(!result.matched, "Completely different products should not match");
        assert!(result.score < 0.2, "Score should be near 0, got {}", result.score);
    }

    #[test]
    fn token_overlap_missing_field() {
        let rule = TokenOverlapMatch {
            field: "product_name".to_string(),
            threshold: 0.5,
            weight: 1.0,
            normalizer: None,
            rule_name: "token_overlap_product_name".to_string(),
        };
        let a = make_entity("p1", vec![("product_name", "Samsung TV")]);
        let b = make_entity("p2", vec![("brand", "Samsung")]);
        let result = rule.evaluate(&a, &b);
        assert!(!result.matched, "Missing field should not match");
        assert_eq!(result.score, 0.0);
        assert_eq!(result.weight, 0.0, "Weight should be 0 when data is missing");
    }

    #[test]
    fn token_overlap_case_insensitive() {
        let rule = TokenOverlapMatch {
            field: "product_name".to_string(),
            threshold: 0.5,
            weight: 1.0,
            normalizer: None,
            rule_name: "token_overlap_product_name".to_string(),
        };
        let a = make_entity("p1", vec![("product_name", "SAMSUNG QLED TV")]);
        let b = make_entity("p2", vec![("product_name", "samsung qled tv")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched);
        assert!((result.score - 1.0).abs() < 0.001, "Case-insensitive comparison should produce Jaccard 1.0");
    }

    #[test]
    fn token_overlap_separator_splits() {
        // Test that hyphens, slashes, pipes, commas are all treated as separators
        let rule = TokenOverlapMatch {
            field: "product_name".to_string(),
            threshold: 0.5,
            weight: 1.0,
            normalizer: None,
            rule_name: "token_overlap_product_name".to_string(),
        };
        let a = make_entity("p1", vec![("product_name", "Samsung-QLED/4K|Smart,TV")]);
        let b = make_entity("p2", vec![("product_name", "Samsung QLED 4K Smart TV")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched);
        assert!((result.score - 1.0).abs() < 0.001);
    }

    #[test]
    fn token_overlap_with_normalizer() {
        let rule = TokenOverlapMatch {
            field: "product_name".to_string(),
            threshold: 0.5,
            weight: 1.0,
            normalizer: Some("brand".to_string()),
            rule_name: "token_overlap_product_name".to_string(),
        };
        let a = make_entity("p1", vec![("product_name", "Apple Inc.")]);
        let b = make_entity("p2", vec![("product_name", "apple")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched);
    }

    // -- NumericAlignmentMatch tests --

    #[test]
    fn numeric_alignment_identical_specs() {
        let rule = NumericAlignmentMatch {
            field: "specs".to_string(),
            threshold: 0.8,
            weight: 1.0,
            rule_name: "numeric_alignment_specs".to_string(),
        };
        let a = make_entity("p1", vec![("specs", "256GB 6.1in")]);
        let b = make_entity("p2", vec![("specs", "256GB 6.1in")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched);
        assert!((result.score - 1.0).abs() < 0.001);
    }

    #[test]
    fn numeric_alignment_unit_conversion() {
        let rule = NumericAlignmentMatch {
            field: "specs".to_string(),
            threshold: 0.8,
            weight: 1.0,
            rule_name: "numeric_alignment_specs".to_string(),
        };
        // 256GB = 256GB, 6.1in = 15.494cm -> should match
        let a = make_entity("p1", vec![("specs", "256GB 6.1in")]);
        let b = make_entity("p2", vec![("specs", "256GB 6.1inches")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched, "Same unit alias should match");
        assert!((result.score - 1.0).abs() < 0.001);
    }

    #[test]
    fn numeric_alignment_cross_unit_conversion() {
        let rule = NumericAlignmentMatch {
            field: "weight".to_string(),
            threshold: 0.8,
            weight: 1.0,
            rule_name: "numeric_alignment_weight".to_string(),
        };
        // 1kg = 1000g, 2.2lb = 997.9g (within 0.5% of 1000g)
        let a = make_entity("p1", vec![("weight", "1kg")]);
        let b = make_entity("p2", vec![("weight", "1000gram")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched, "1kg and 1000gram should match");
    }

    #[test]
    fn numeric_alignment_storage_tb_gb() {
        let rule = NumericAlignmentMatch {
            field: "storage".to_string(),
            threshold: 0.8,
            weight: 1.0,
            rule_name: "numeric_alignment_storage".to_string(),
        };
        let a = make_entity("p1", vec![("storage", "1TB")]);
        let b = make_entity("p2", vec![("storage", "1024GB")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched, "1TB and 1024GB should match");
    }

    #[test]
    fn numeric_alignment_different_values() {
        let rule = NumericAlignmentMatch {
            field: "specs".to_string(),
            threshold: 0.8,
            weight: 1.0,
            rule_name: "numeric_alignment_specs".to_string(),
        };
        let a = make_entity("p1", vec![("specs", "256GB 6.1in")]);
        let b = make_entity("p2", vec![("specs", "512GB 6.7in")]);
        let result = rule.evaluate(&a, &b);
        assert!(!result.matched, "Different numeric values should not match");
        assert!(result.score < 0.5, "Score should be low for different values");
    }

    #[test]
    fn numeric_alignment_missing_field() {
        let rule = NumericAlignmentMatch {
            field: "specs".to_string(),
            threshold: 0.8,
            weight: 1.0,
            rule_name: "numeric_alignment_specs".to_string(),
        };
        let a = make_entity("p1", vec![("specs", "256GB")]);
        let b = make_entity("p2", vec![("brand", "Samsung")]);
        let result = rule.evaluate(&a, &b);
        assert!(!result.matched);
        assert_eq!(result.score, 0.0);
        assert_eq!(result.weight, 0.0, "Weight should be 0 when data is missing");
    }

    #[test]
    fn numeric_alignment_no_numbers() {
        let rule = NumericAlignmentMatch {
            field: "description".to_string(),
            threshold: 0.8,
            weight: 1.0,
            rule_name: "numeric_alignment_description".to_string(),
        };
        let a = make_entity("p1", vec![("description", "no numbers here")]);
        let b = make_entity("p2", vec![("description", "also no numbers")]);
        let result = rule.evaluate(&a, &b);
        assert!(!result.matched);
        assert_eq!(result.score, 0.0);
    }

    #[test]
    fn numeric_alignment_partial_match() {
        let rule = NumericAlignmentMatch {
            field: "specs".to_string(),
            threshold: 0.4,
            weight: 1.0,
            rule_name: "numeric_alignment_specs".to_string(),
        };
        // Same storage (256GB) but different screen size
        let a = make_entity("p1", vec![("specs", "256GB 6.1in")]);
        let b = make_entity("p2", vec![("specs", "256GB 6.7in")]);
        let result = rule.evaluate(&a, &b);
        // 1 of 2 numeric-unit pairs match -> score = 0.5
        assert!(result.matched, "1/2 match with threshold 0.4 should pass");
        assert!((result.score - 0.5).abs() < 0.001, "Score should be 0.5, got {}", result.score);
    }

    #[test]
    fn numeric_alignment_frequency_ghz_mhz() {
        let rule = NumericAlignmentMatch {
            field: "cpu".to_string(),
            threshold: 0.8,
            weight: 1.0,
            rule_name: "numeric_alignment_cpu".to_string(),
        };
        let a = make_entity("p1", vec![("cpu", "3.5GHz")]);
        let b = make_entity("p2", vec![("cpu", "3500MHz")]);
        let result = rule.evaluate(&a, &b);
        assert!(result.matched, "3.5GHz and 3500MHz should match");
    }

    // -- Tokenize helper tests --

    #[test]
    fn tokenize_empty_sets() {
        let empty_a: HashSet<String> = HashSet::new();
        let empty_b: HashSet<String> = HashSet::new();
        assert_eq!(TokenOverlapMatch::jaccard(&empty_a, &empty_b), 0.0);
    }

    #[test]
    fn tokenize_disjoint_sets() {
        let a: HashSet<String> = ["samsung", "tv"].iter().map(|s| s.to_string()).collect();
        let b: HashSet<String> = ["apple", "phone"].iter().map(|s| s.to_string()).collect();
        assert_eq!(TokenOverlapMatch::jaccard(&a, &b), 0.0);
    }

    // -- NumericAlignmentMatch::extract_numerics tests --

    #[test]
    fn extract_numerics_basic() {
        let nums = NumericAlignmentMatch::extract_numerics("256GB 6.1in");
        assert_eq!(nums.len(), 2);
        assert_eq!(nums[0].0, 256.0);
        assert_eq!(nums[0].1, "storage_gb");
        // 6.1in -> 6.1 * 2.54 cm
        assert!((nums[1].0 - 6.1 * 2.54).abs() < 0.01);
        assert_eq!(nums[1].1, "length_cm");
    }

    #[test]
    fn extract_numerics_no_unit() {
        let nums = NumericAlignmentMatch::extract_numerics("42");
        assert_eq!(nums.len(), 1);
        assert_eq!(nums[0].0, 42.0);
        assert_eq!(nums[0].1, "dimensionless");
    }

    #[test]
    fn extract_numerics_empty() {
        let nums = NumericAlignmentMatch::extract_numerics("no numbers");
        assert!(nums.is_empty());
    }

    // -- exact_field_info tests --

    #[test]
    fn exact_field_info_returns_correct_values() {
        let rule = 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 info = rule.exact_field_info().unwrap();
        assert_eq!(info.0, "barcode");
        assert_eq!(info.1, 1.0);
        assert!(!info.2); // case_insensitive = false
        assert!(info.3);  // normalize = true
        assert_eq!(info.4, &Some("barcode".to_string()));
    }

    #[test]
    fn exact_field_info_case_insensitive_flag() {
        let rule = ExactMatch {
            field: "mpn".to_string(),
            weight: 0.9,
            case_insensitive: true,
            normalize: false,
            normalizer: None,
            rule_name: "mpn_exact".to_string(),
        };
        let info = rule.exact_field_info().unwrap();
        assert_eq!(info.0, "mpn");
        assert_eq!(info.1, 0.9);
        assert!(info.2);   // case_insensitive = true
        assert!(!info.3);  // normalize = false
        assert_eq!(info.4, &None);
    }

    #[test]
    fn exact_field_info_none_for_non_exact_rules() {
        // FuzzyStringMatch should return None
        let fuzzy = FuzzyStringMatch {
            field: "name".to_string(),
            threshold: 0.8,
            weight: 0.5,
            normalizer: None,
            rule_name: "name_fuzzy".to_string(),
        };
        assert!(fuzzy.exact_field_info().is_none());

        // RangeMatch should return None
        let range = RangeMatch {
            field: "price".to_string(),
            tolerance: 10.0,
            weight: 0.5,
            normalizer: None,
            rule_name: "price_range".to_string(),
        };
        assert!(range.exact_field_info().is_none());

        // TokenOverlapMatch should return None
        let token = TokenOverlapMatch {
            field: "description".to_string(),
            threshold: 0.5,
            weight: 0.5,
            normalizer: None,
            rule_name: "desc_tokens".to_string(),
        };
        assert!(token.exact_field_info().is_none());
    }
}
