//! Signal detection: identity signal types, regex patterns, and value-based detection.

use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;

// ---------------------------------------------------------------------------
// Signal patterns (port of Python _SIGNAL_PATTERNS, lines 26-47)
// ---------------------------------------------------------------------------

/// Canonical identity signal types detected from column names or values.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentitySignal {
    // Person signals
    Email,
    Phone,
    FirstName,
    LastName,
    FullName,
    CompanyName,
    // Company signals
    Domain,
    TaxId,
    Industry,
    // Product signals
    Sku,
    Asin,
    Upc,
    Gtin,
    Mpn,
    ProductName,
    Brand,
    Category,
    ModelNumber,
    Color,
    Size,
    Capacity,
    ParentProductId,
    // Transaction signals
    TransactionId,
    Amount,
    Currency,
    // Healthcare signals
    Mrn,
    Npi,
    PatientName,
    DateOfBirth,
}

impl IdentitySignal {
    /// Canonical field name used in the IdentityPlan.
    pub fn canonical_name(&self) -> &'static str {
        match self {
            Self::Email => "email",
            Self::Phone => "phone",
            Self::FirstName => "first_name",
            Self::LastName => "last_name",
            Self::FullName => "full_name",
            Self::CompanyName => "company_name",
            Self::Domain => "domain",
            Self::TaxId => "tax_id",
            Self::Industry => "industry",
            Self::Sku => "sku",
            Self::Asin => "asin",
            Self::Upc => "upc",
            Self::Gtin => "gtin",
            Self::Mpn => "mpn",
            Self::ProductName => "product_name",
            Self::Brand => "brand",
            Self::Category => "category",
            Self::ModelNumber => "model_number",
            Self::Color => "color",
            Self::Size => "size",
            Self::Capacity => "capacity",
            Self::ParentProductId => "parent_product_id",
            Self::TransactionId => "transaction_id",
            Self::Amount => "amount",
            Self::Currency => "currency",
            Self::Mrn => "mrn",
            Self::Npi => "npi",
            Self::PatientName => "patient_name",
            Self::DateOfBirth => "date_of_birth",
        }
    }
}

/// Entity type inferred from data signals. This is a profiler concept - the engine
/// just executes whatever rules/fields are in the `IdentityPlan`. The profiler uses
/// `EntityType` to select which signals, defaults, and blocking keys to generate.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntityType {
    Person,
    Company,
    Product,
    Transaction,
    Healthcare,
    Custom(String),
}

impl EntityType {
    /// Entity name for use in `IdentityPlan.entity`.
    pub fn to_entity_name(&self) -> &str {
        match self {
            Self::Person => "person",
            Self::Company => "company",
            Self::Product => "product",
            Self::Transaction => "transaction",
            Self::Healthcare => "healthcare",
            Self::Custom(name) => name.as_str(),
        }
    }

    /// Parse from a string. Unknown strings become `Custom`.
    pub fn from_str_loose(s: &str) -> Self {
        match s.to_lowercase().as_str() {
            "person" | "sf_person" | "contact" | "lead" => Self::Person,
            "company" | "organization" | "org" | "account" => Self::Company,
            "product" | "item" | "sku" => Self::Product,
            "transaction" | "order" | "payment" | "invoice" => Self::Transaction,
            "healthcare" | "patient" | "provider" | "medical" => Self::Healthcare,
            _ => Self::Custom(s.to_string()),
        }
    }

    /// Display name for CLI output and API responses.
    pub fn display_name(&self) -> &str {
        match self {
            Self::Person => "person",
            Self::Company => "company",
            Self::Product => "product",
            Self::Transaction => "transaction",
            Self::Healthcare => "healthcare",
            Self::Custom(name) => name.as_str(),
        }
    }
}

pub(crate) struct SignalPattern {
    pub signal: IdentitySignal,
    pub pattern: Regex,
}

pub(crate) fn signal_patterns() -> &'static Vec<SignalPattern> {
    static PATTERNS: OnceLock<Vec<SignalPattern>> = OnceLock::new();
    PATTERNS.get_or_init(|| {
        vec![
            // --- Person signals ---
            SignalPattern {
                signal: IdentitySignal::Email,
                pattern: Regex::new(
                    r"(?i)^(e_?mail|email_address|user_email|contact_email)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Phone,
                pattern: Regex::new(
                    r"(?i)^(phone|phone_number|telephone|mobile|cell|tel)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::FirstName,
                pattern: Regex::new(r"(?i)^(first_name|given_name|fname|first)$").unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::LastName,
                pattern: Regex::new(r"(?i)^(last_name|family_name|surname|lname|last)$").unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::FullName,
                pattern: Regex::new(
                    r"(?i)^(full_name|display_name|contact_name|customer_name|name)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::CompanyName,
                pattern: Regex::new(
                    r"(?i)^(company|company_name|account_name|org|organization|employer)$",
                )
                .unwrap(),
            },
            // --- Company signals ---
            SignalPattern {
                signal: IdentitySignal::Domain,
                pattern: Regex::new(
                    r"(?i)^(domain|website_domain|company_domain|web_domain)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::TaxId,
                pattern: Regex::new(
                    r"(?i)^(tax_id|ein|vat_number|tax_number|vat_id|tax_identifier)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Industry,
                pattern: Regex::new(
                    r"(?i)^(industry|sector|business_type|vertical)$",
                )
                .unwrap(),
            },
            // --- Product signals ---
            SignalPattern {
                signal: IdentitySignal::Sku,
                pattern: Regex::new(
                    r"(?i)^(sku|item_code|product_code|item_number|stock_keeping_unit|supplier_sku|vendor_sku|product_sku|seller_sku|internal_sku|sku_code|sku_id|item_sku)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Asin,
                pattern: Regex::new(
                    r"(?i)^(asin|amazon_id|amazon_asin|amazon_sku)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Upc,
                pattern: Regex::new(
                    r"(?i)^(upc|ean|barcode|gtin|upc_code|ean_code)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::ProductName,
                pattern: Regex::new(
                    r"(?i)^(product_name|item_name|product_title|product_description|product)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Brand,
                pattern: Regex::new(
                    r"(?i)^(brand|brand_name|manufacturer|vendor|maker)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Category,
                pattern: Regex::new(
                    r"(?i)^(category|product_category|department|product_type|item_category)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Gtin,
                pattern: Regex::new(
                    r"(?i)^(gtin|gtin_14|gtin_13|gtin_12|gtin_8)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Mpn,
                pattern: Regex::new(
                    r"(?i)^(mpn|mfr_part|manufacturer_part|part_number|mfr_num|manufacturer_part_number)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::ModelNumber,
                pattern: Regex::new(
                    r"(?i)^(model|model_number|model_num|model_id|model_no|model_name)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Color,
                pattern: Regex::new(
                    r"(?i)^(color|colour|color_name|colour_name)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Size,
                pattern: Regex::new(
                    r"(?i)^(size|dimensions?|length|width|height|screen_size|display_size)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Capacity,
                pattern: Regex::new(
                    r"(?i)^(capacity|storage|memory|ram|volume|weight|storage_capacity)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::ParentProductId,
                pattern: Regex::new(
                    r"(?i)^(parent_id|parent_product|parent_sku|master_id|group_id|parent_product_id)$",
                )
                .unwrap(),
            },
            // --- Transaction signals ---
            SignalPattern {
                signal: IdentitySignal::TransactionId,
                pattern: Regex::new(
                    r"(?i)^(transaction_id|txn_id|order_id|payment_id|invoice_id|order_number)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Amount,
                pattern: Regex::new(
                    r"(?i)^(amount|total|price|subtotal|grand_total|order_total|payment_amount)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Currency,
                pattern: Regex::new(
                    r"(?i)^(currency|currency_code|currency_type)$",
                )
                .unwrap(),
            },
            // --- Healthcare signals ---
            SignalPattern {
                signal: IdentitySignal::Mrn,
                pattern: Regex::new(
                    r"(?i)^(mrn|medical_record_number|medical_record|chart_number)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::Npi,
                pattern: Regex::new(
                    r"(?i)^(npi|provider_id|physician_id|provider_number)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::PatientName,
                pattern: Regex::new(
                    r"(?i)^(patient_name|patient_full_name|patient)$",
                )
                .unwrap(),
            },
            SignalPattern {
                signal: IdentitySignal::DateOfBirth,
                pattern: Regex::new(
                    r"(?i)^(date_of_birth|dob|birth_date|birthdate|birthday)$",
                )
                .unwrap(),
            },
        ]
    })
}

pub(crate) fn email_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^[^@\s]+@[^@\s]+\.[a-zA-Z]{2,}$").unwrap())
}

pub(crate) fn phone_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^[\d\s\-\+\(\)\.]{7,}$").unwrap())
}

pub(crate) fn timestamp_col_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"(?i)(timestamp|created_at|updated_at|event_time|occurred_at|_at$|_time$|_date$)")
            .unwrap()
    })
}

pub(crate) fn fk_col_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?i)^.+_(id|key|fk)$").unwrap())
}

/// Matches values that contain corporate/organization suffixes.
pub(crate) fn company_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"(?i)\b(inc\.?|incorporated|corp\.?|corporation|ltd\.?|limited|llc|llp|gmbh|ag|sa|pty|plc|co\.|group|holdings|partners|associates|enterprises|solutions|services|technologies|consulting|foundation|university|institute|bank)\b"
        ).unwrap()
    })
}

/// Matches SKU-like values: short alphanumeric codes with optional dash/underscore.
pub(crate) fn sku_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^[A-Za-z]{1,5}[-_]?\d{3,}[A-Za-z0-9]*$").unwrap())
}

/// Matches Amazon ASINs: exactly 10 chars starting with B0 followed by 8 alphanumeric.
pub(crate) fn asin_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?i)^B0[A-Z0-9]{8}$").unwrap())
}

/// Matches UPC/EAN barcodes: 12-13 digit numbers.
pub(crate) fn upc_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^\d{12,13}$").unwrap())
}

/// Matches GTIN barcodes: 12, 13, or 14 digit numbers (covers GTIN-12/13/14).
/// GTIN-8 excluded from value detection because 8-digit numbers overlap with NPI/other IDs.
/// Column name detection still catches GTIN-8 fields explicitly named "gtin_8".
pub(crate) fn gtin_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^\d{12,14}$").unwrap())
}

/// Matches MPN-like values: alphanumeric with optional hyphens, 4+ chars.
pub(crate) fn mpn_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^[A-Za-z0-9][-A-Za-z0-9]{2,}[A-Za-z0-9]$").unwrap())
}

/// Check if a value looks like an MPN: matches regex AND contains both letters and digits.
pub(crate) fn is_mpn_value(v: &str) -> bool {
    mpn_value_re().is_match(v)
        && v.chars().any(|c| c.is_ascii_alphabetic())
        && v.chars().any(|c| c.is_ascii_digit())
}

/// Validate a GTIN/UPC/EAN check digit using the GS1 mod-10 algorithm.
/// Accepts 8, 12, 13, or 14 digit strings (after stripping non-digits).
/// Returns false for invalid lengths or failed check digit.
pub(crate) fn is_valid_gtin_check_digit(value: &str) -> bool {
    let digits: Vec<u32> = value.chars().filter(|c| c.is_ascii_digit()).filter_map(|c| c.to_digit(10)).collect();
    if !matches!(digits.len(), 8 | 12 | 13 | 14) {
        return false;
    }
    // GS1 mod-10: alternating weights 3,1 from right-to-left (excluding check digit)
    // then check digit = (10 - sum % 10) % 10
    let len = digits.len();
    let mut sum = 0u32;
    for (i, &d) in digits[..len - 1].iter().rev().enumerate() {
        let weight = if i % 2 == 0 { 3 } else { 1 };
        sum += d * weight;
    }
    let expected_check = (10 - (sum % 10)) % 10;
    digits[len - 1] == expected_check
}

/// Check if a GTIN has a restricted prefix (store-internal barcodes, not universal).
/// Prefixes 020-029, 040-049, 200-299 are reserved for in-store use.
pub(crate) fn has_restricted_gtin_prefix(value: &str) -> bool {
    let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect();
    if digits.len() < 3 {
        return false;
    }
    let prefix: u32 = match digits[..3].parse() {
        Ok(p) => p,
        Err(_) => return false,
    };
    (20..=29).contains(&prefix) || (40..=49).contains(&prefix) || (200..=299).contains(&prefix)
}

/// Matches monetary amounts. Requires either a currency symbol prefix OR a decimal with cents.
/// Plain integers like "42" or "2024" do NOT match (avoids false positives on age/quantity/year columns).
pub(crate) fn amount_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"^([\$\u{00a3}\u{20ac}]\d[\d,]*\.?\d{0,2}|\d[\d,]*\.\d{2})$").unwrap()
    })
}

/// Matches ISO 4217 currency codes (3 uppercase letters like USD, EUR, GBP).
pub(crate) fn currency_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^[A-Z]{3}$").unwrap())
}

/// Matches US EIN tax IDs: XX-XXXXXXX format.
pub(crate) fn tax_id_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^\d{2}-\d{7}$").unwrap())
}

/// Matches bare domain names (not emails): alphanumeric-dot-tld.
pub(crate) fn domain_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$").unwrap())
}

/// Matches NPI numbers: exactly 10 digits.
pub(crate) fn npi_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^\d{10}$").unwrap())
}

/// Matches MRN (Medical Record Numbers): requires MRN/MR prefix or dash separator
/// to avoid false-positives on plain 6-10 digit numbers (zip codes, IDs, etc.).
pub(crate) fn mrn_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?i)^(MRN|MR)[-# ]?\d{6,10}$").unwrap())
}

/// Matches date-of-birth-like values: YYYY-MM-DD or MM/DD/YYYY with year in 1920-2020.
pub(crate) fn dob_value_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^(19[2-9]\d|20[01]\d|2020)[-/](0[1-9]|1[0-2])[-/](0[1-9]|[12]\d|3[01])$|^(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])/(19[2-9]\d|20[01]\d|2020)$").unwrap())
}

// ---------------------------------------------------------------------------
// Signal detection
// ---------------------------------------------------------------------------

/// Detect an identity signal from a column name alone.
pub fn detect_signal(col_name: &str) -> Option<IdentitySignal> {
    for sp in signal_patterns() {
        if sp.pattern.is_match(col_name) {
            return Some(sp.signal.clone());
        }
    }
    None
}

/// Parse a canonical signal name string back into an IdentitySignal.
/// Used for user-declared field mappings (e.g. --map "product_name=title").
pub fn signal_from_canonical(name: &str) -> Option<IdentitySignal> {
    match name {
        "email" => Some(IdentitySignal::Email),
        "phone" => Some(IdentitySignal::Phone),
        "first_name" => Some(IdentitySignal::FirstName),
        "last_name" => Some(IdentitySignal::LastName),
        "full_name" => Some(IdentitySignal::FullName),
        "company_name" => Some(IdentitySignal::CompanyName),
        "domain" => Some(IdentitySignal::Domain),
        "tax_id" => Some(IdentitySignal::TaxId),
        "industry" => Some(IdentitySignal::Industry),
        "sku" => Some(IdentitySignal::Sku),
        "asin" => Some(IdentitySignal::Asin),
        "upc" => Some(IdentitySignal::Upc),
        "gtin" => Some(IdentitySignal::Gtin),
        "mpn" => Some(IdentitySignal::Mpn),
        "product_name" => Some(IdentitySignal::ProductName),
        "brand" => Some(IdentitySignal::Brand),
        "category" => Some(IdentitySignal::Category),
        "model_number" => Some(IdentitySignal::ModelNumber),
        "color" => Some(IdentitySignal::Color),
        "size" => Some(IdentitySignal::Size),
        "capacity" => Some(IdentitySignal::Capacity),
        "parent_product_id" => Some(IdentitySignal::ParentProductId),
        "transaction_id" => Some(IdentitySignal::TransactionId),
        "amount" => Some(IdentitySignal::Amount),
        "currency" => Some(IdentitySignal::Currency),
        "mrn" => Some(IdentitySignal::Mrn),
        "npi" => Some(IdentitySignal::Npi),
        "patient_name" => Some(IdentitySignal::PatientName),
        "date_of_birth" => Some(IdentitySignal::DateOfBirth),
        _ => None,
    }
}

/// Detect an identity signal by inspecting sampled values. Values are the
/// ground truth - column names are unreliable hints. Returns `Some` if a
/// sufficient fraction of values match a signal pattern.
pub fn detect_signal_from_values(values: &[&str]) -> Option<IdentitySignal> {
    if values.is_empty() {
        return None;
    }
    let n = values.len() as f64;

    // Email: > 50% look like email addresses
    let email_hits = values
        .iter()
        .filter(|v| email_value_re().is_match(v))
        .count();
    if (email_hits as f64) / n > 0.5 {
        return Some(IdentitySignal::Email);
    }

    // --- Specific numeric patterns BEFORE phone (phone regex is broad) ---

    // GTIN: > 30% are 8/12/13/14 digit barcodes (broader than UPC)
    let gtin_hits = values.iter().filter(|v| gtin_value_re().is_match(v)).count();
    if (gtin_hits as f64) / n > 0.3 {
        // Distinguish: if most are exactly 12-13 digits, call it UPC for backward compat
        let upc_hits = values.iter().filter(|v| upc_value_re().is_match(v)).count();
        if (upc_hits as f64) / n > 0.3 {
            return Some(IdentitySignal::Upc);
        }
        return Some(IdentitySignal::Gtin);
    }

    // GTIN by check digit: catches "sku" columns that actually contain barcodes.
    // If >80% of 12-14 digit values pass the GS1 mod-10 check digit, it's GTIN.
    // Random digits pass ~10% of the time, so >80% is conclusive.
    {
        let digit_12_14: Vec<&&str> = values.iter()
            .filter(|v| {
                let dlen = v.chars().filter(|c| c.is_ascii_digit()).count();
                (12..=14).contains(&dlen)
            })
            .collect();
        if digit_12_14.len() >= 3 {
            let valid = digit_12_14.iter().filter(|v| is_valid_gtin_check_digit(v)).count();
            let ratio = valid as f64 / digit_12_14.len() as f64;
            if ratio > 0.8 {
                // Skip reclassification if >50% have restricted prefixes (store-internal)
                let restricted = digit_12_14.iter().filter(|v| has_restricted_gtin_prefix(v)).count();
                let restricted_ratio = restricted as f64 / digit_12_14.len() as f64;
                if restricted_ratio <= 0.5 {
                    let upc_hits = digit_12_14.iter().filter(|v| upc_value_re().is_match(v)).count();
                    if (upc_hits as f64 / digit_12_14.len() as f64) > 0.5 {
                        return Some(IdentitySignal::Upc);
                    }
                    return Some(IdentitySignal::Gtin);
                }
            }
        }
    }

    // ASIN: > 30% match B0 prefix + 8 alphanumeric
    let asin_hits = values.iter().filter(|v| asin_value_re().is_match(v)).count();
    if (asin_hits as f64) / n > 0.3 {
        return Some(IdentitySignal::Asin);
    }

    // NPI: > 30% are exactly 10-digit numbers
    let npi_hits = values.iter().filter(|v| npi_value_re().is_match(v)).count();
    if (npi_hits as f64) / n > 0.3 {
        return Some(IdentitySignal::Npi);
    }

    // Tax ID (EIN): > 30% match XX-XXXXXXX pattern
    let tax_hits = values.iter().filter(|v| tax_id_value_re().is_match(v)).count();
    if (tax_hits as f64) / n > 0.3 {
        return Some(IdentitySignal::TaxId);
    }

    // MRN: > 30% match MRN prefix + digits pattern
    let mrn_hits = values.iter().filter(|v| mrn_value_re().is_match(v)).count();
    if (mrn_hits as f64) / n > 0.3 {
        return Some(IdentitySignal::Mrn);
    }

    // Date of Birth: > 30% match date patterns in birth-year range
    let dob_hits = values.iter().filter(|v| dob_value_re().is_match(v)).count();
    if (dob_hits as f64) / n > 0.3 {
        return Some(IdentitySignal::DateOfBirth);
    }

    // Phone: > 50% look like phone numbers (checked AFTER specific digit patterns
    // because the phone regex is broad and matches UPCs, NPIs, tax IDs, dates, etc.)
    let phone_hits = values
        .iter()
        .filter(|v| phone_value_re().is_match(v))
        .count();
    if (phone_hits as f64) / n > 0.5 {
        return Some(IdentitySignal::Phone);
    }

    // --- Non-numeric patterns ---

    // SKU: > 30% match short alphanumeric code pattern
    let sku_hits = values.iter().filter(|v| sku_value_re().is_match(v)).count();
    if (sku_hits as f64) / n > 0.3 {
        return Some(IdentitySignal::Sku);
    }

    // MPN: > 30% match alphanumeric part number pattern (must have both letters and digits)
    let mpn_hits = values.iter().filter(|v| is_mpn_value(v)).count();
    if (mpn_hits as f64) / n > 0.3 {
        return Some(IdentitySignal::Mpn);
    }

    // Domain: > 40% match bare domain pattern (not emails)
    let domain_hits = values
        .iter()
        .filter(|v| domain_value_re().is_match(&v.to_lowercase()) && !v.contains('@'))
        .count();
    if (domain_hits as f64) / n > 0.4 {
        return Some(IdentitySignal::Domain);
    }

    // Currency codes: > 50% match 3-letter ISO codes
    let currency_hits = values.iter().filter(|v| currency_value_re().is_match(v)).count();
    if (currency_hits as f64) / n > 0.5 {
        return Some(IdentitySignal::Currency);
    }

    // Amount: > 50% match monetary amount patterns
    let amount_hits = values.iter().filter(|v| amount_value_re().is_match(v)).count();
    if (amount_hits as f64) / n > 0.5 {
        return Some(IdentitySignal::Amount);
    }

    // Product name: NOT detected from values. Product names are free-text and
    // too ambiguous to distinguish from addresses, prose, or other multi-word
    // strings. ProductName is spec-driven: auto-detected only via column name
    // regex (product_name, item_name) or user-declared --map in the CLI.
    // The entity catalog defines whether product_name is a blocking key or
    // scoring feature. The CLI maps. The spec defines. The engine executes.

    // Company name: > 20% contain corporate suffixes (Ltd, Inc, Corp, LLC, etc.)
    // Lower threshold because not all companies have suffixes but a meaningful
    // fraction is a strong signal this column holds org names, not person names.
    let company_hits = values
        .iter()
        .filter(|v| company_value_re().is_match(v))
        .count();
    if (company_hits as f64) / n > 0.2 {
        return Some(IdentitySignal::CompanyName);
    }

    None
}

/// Returns true if the column name looks like a timestamp field.
pub fn is_timestamp_column(col_name: &str) -> bool {
    timestamp_col_re().is_match(col_name)
}

/// Returns true if the column name looks like a foreign key.
pub fn is_fk_column(col_name: &str) -> bool {
    fk_col_re().is_match(col_name)
}

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

    #[test]
    fn test_detect_signal_email() {
        assert_eq!(detect_signal("email"), Some(IdentitySignal::Email));
        assert_eq!(detect_signal("Email"), Some(IdentitySignal::Email));
        assert_eq!(detect_signal("email_address"), Some(IdentitySignal::Email));
        assert_eq!(detect_signal("user_email"), Some(IdentitySignal::Email));
        assert_eq!(detect_signal("contact_email"), Some(IdentitySignal::Email));
    }

    #[test]
    fn test_detect_signal_phone() {
        assert_eq!(detect_signal("phone"), Some(IdentitySignal::Phone));
        assert_eq!(detect_signal("Phone_Number"), Some(IdentitySignal::Phone));
        assert_eq!(detect_signal("mobile"), Some(IdentitySignal::Phone));
        assert_eq!(detect_signal("cell"), Some(IdentitySignal::Phone));
        assert_eq!(detect_signal("tel"), Some(IdentitySignal::Phone));
    }

    #[test]
    fn test_detect_signal_names() {
        assert_eq!(detect_signal("first_name"), Some(IdentitySignal::FirstName));
        assert_eq!(detect_signal("fname"), Some(IdentitySignal::FirstName));
        assert_eq!(detect_signal("last_name"), Some(IdentitySignal::LastName));
        assert_eq!(detect_signal("surname"), Some(IdentitySignal::LastName));
        assert_eq!(detect_signal("full_name"), Some(IdentitySignal::FullName));
        assert_eq!(detect_signal("display_name"), Some(IdentitySignal::FullName));
    }

    #[test]
    fn test_detect_signal_company() {
        assert_eq!(detect_signal("company"), Some(IdentitySignal::CompanyName));
        assert_eq!(detect_signal("company_name"), Some(IdentitySignal::CompanyName));
        assert_eq!(detect_signal("account_name"), Some(IdentitySignal::CompanyName));
        assert_eq!(detect_signal("organization"), Some(IdentitySignal::CompanyName));
        assert_eq!(detect_signal("employer"), Some(IdentitySignal::CompanyName));
    }

    #[test]
    fn test_detect_signal_none() {
        assert_eq!(detect_signal("foo_bar"), None);
        assert_eq!(detect_signal("status"), None);
        assert_eq!(detect_signal("created_at"), None);
    }

    #[test]
    fn test_detect_signal_from_values_email() {
        let vals = vec!["alice@example.com", "bob@test.org", "charlie@foo.net"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Email));
    }

    #[test]
    fn test_detect_signal_from_values_phone() {
        let vals = vec!["+1-555-123-4567", "(555) 987-6543", "555.111.2222"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Phone));
    }

    #[test]
    fn test_detect_signal_from_values_company() {
        let vals = vec![
            "Grand Hotels & Resorts Ltd",
            "Edge Communications",
            "United Oil & Gas Corp.",
            "Acme Inc.",
            "sForce",
        ];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::CompanyName));
    }

    #[test]
    fn test_detect_signal_from_values_company_mixed() {
        let vals = vec!["Acme LLC", "John Smith", "Jane Doe", "Some Company Inc.", "Alice Johnson"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::CompanyName));
    }

    #[test]
    fn test_detect_signal_from_values_person_names() {
        let vals = vec!["John Smith", "Jane Doe", "Alice Johnson", "Bob Williams"];
        assert_eq!(detect_signal_from_values(&vals), None);
    }

    #[test]
    fn test_values_override_name_hint_for_company() {
        let name_hint = detect_signal("name");
        assert_eq!(name_hint, Some(IdentitySignal::FullName));
        let vals = vec!["Grand Hotels & Resorts Ltd", "Edge Communications Inc.", "United Oil Corp."];
        let value_signal = detect_signal_from_values(&vals);
        assert_eq!(value_signal, Some(IdentitySignal::CompanyName));
        let signal = match (&name_hint, &value_signal) {
            (Some(IdentitySignal::FullName), Some(IdentitySignal::CompanyName)) => &value_signal,
            _ => &name_hint,
        };
        assert_eq!(*signal, Some(IdentitySignal::CompanyName));
    }

    #[test]
    fn test_detect_signal_from_values_none() {
        let vals = vec!["hello", "world", "foo"];
        assert_eq!(detect_signal_from_values(&vals), None);
    }

    #[test]
    fn test_detect_signal_from_values_empty() {
        assert_eq!(detect_signal_from_values(&[]), None);
    }

    #[test]
    fn test_is_timestamp_column() {
        assert!(is_timestamp_column("created_at"));
        assert!(is_timestamp_column("updated_at"));
        assert!(is_timestamp_column("event_time"));
        assert!(!is_timestamp_column("email"));
        assert!(!is_timestamp_column("name"));
    }

    #[test]
    fn test_is_fk_column() {
        assert!(is_fk_column("account_id"));
        assert!(is_fk_column("user_key"));
        assert!(is_fk_column("parent_fk"));
        assert!(!is_fk_column("email"));
        assert!(!is_fk_column("name"));
    }

    #[test]
    fn test_detect_signal_product_columns() {
        assert_eq!(detect_signal("sku"), Some(IdentitySignal::Sku));
        assert_eq!(detect_signal("item_code"), Some(IdentitySignal::Sku));
        assert_eq!(detect_signal("upc"), Some(IdentitySignal::Upc));
        assert_eq!(detect_signal("ean"), Some(IdentitySignal::Upc));
        assert_eq!(detect_signal("barcode"), Some(IdentitySignal::Upc));
        assert_eq!(detect_signal("product_name"), Some(IdentitySignal::ProductName));
        assert_eq!(detect_signal("brand"), Some(IdentitySignal::Brand));
        assert_eq!(detect_signal("category"), Some(IdentitySignal::Category));
    }

    #[test]
    fn test_detect_signal_company_columns() {
        assert_eq!(detect_signal("domain"), Some(IdentitySignal::Domain));
        assert_eq!(detect_signal("tax_id"), Some(IdentitySignal::TaxId));
        assert_eq!(detect_signal("ein"), Some(IdentitySignal::TaxId));
        assert_eq!(detect_signal("industry"), Some(IdentitySignal::Industry));
    }

    #[test]
    fn test_detect_signal_transaction_columns() {
        assert_eq!(detect_signal("transaction_id"), Some(IdentitySignal::TransactionId));
        assert_eq!(detect_signal("order_id"), Some(IdentitySignal::TransactionId));
        assert_eq!(detect_signal("amount"), Some(IdentitySignal::Amount));
        assert_eq!(detect_signal("currency"), Some(IdentitySignal::Currency));
    }

    #[test]
    fn test_detect_signal_healthcare_columns() {
        assert_eq!(detect_signal("mrn"), Some(IdentitySignal::Mrn));
        assert_eq!(detect_signal("npi"), Some(IdentitySignal::Npi));
        assert_eq!(detect_signal("patient_name"), Some(IdentitySignal::PatientName));
        assert_eq!(detect_signal("date_of_birth"), Some(IdentitySignal::DateOfBirth));
        assert_eq!(detect_signal("dob"), Some(IdentitySignal::DateOfBirth));
    }

    #[test]
    fn test_detect_signal_from_values_sku() {
        let vals = vec!["AB-1234", "CD-5678", "EF-9012", "GH-3456"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Sku));
    }

    #[test]
    fn test_detect_signal_from_values_upc() {
        let vals = vec!["012345678901", "123456789012", "234567890123"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Upc));
    }

    #[test]
    fn test_detect_signal_from_values_tax_id() {
        let vals = vec!["12-3456789", "98-7654321", "45-6789012", "11-2233445"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::TaxId));
    }

    #[test]
    fn test_detect_signal_from_values_domain() {
        let vals = vec!["acme.com", "example.org", "bigcorp.io", "startup.dev", "test.co"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Domain));
    }

    #[test]
    fn test_detect_signal_from_values_npi() {
        let vals = vec!["1234567890", "0987654321", "1122334455"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Npi));
    }

    #[test]
    fn test_detect_signal_from_values_mrn() {
        let vals = vec!["MRN123456", "MRN789012", "MR-345678", "MRN901234"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Mrn));
    }

    #[test]
    fn test_mrn_regex_rejects_bare_digits() {
        let vals = vec!["123456", "789012", "345678", "901234"];
        assert_ne!(detect_signal_from_values(&vals), Some(IdentitySignal::Mrn));
    }

    #[test]
    fn test_detect_signal_from_values_dob() {
        let vals = vec!["1985-06-15", "1990-01-20", "1978-12-03", "2001-03-10"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::DateOfBirth));
    }

    #[test]
    fn test_detect_signal_from_values_currency() {
        let vals = vec!["USD", "EUR", "GBP", "JPY"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Currency));
    }

    #[test]
    fn test_detect_signal_from_values_amount() {
        let vals = vec!["$19.99", "$249.00", "$5.50", "$1,299.99"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Amount));
    }

    #[test]
    fn test_detect_signal_from_values_amount_without_symbol() {
        let vals = vec!["19.99", "249.00", "5.50", "1299.99"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Amount));
    }

    #[test]
    fn test_plain_integers_not_detected_as_amount() {
        let vals = vec!["42", "100", "2024", "7"];
        assert_ne!(detect_signal_from_values(&vals), Some(IdentitySignal::Amount));
    }

    #[test]
    fn test_entity_type_from_str_loose() {
        assert_eq!(EntityType::from_str_loose("person"), EntityType::Person);
        assert_eq!(EntityType::from_str_loose("sf_person"), EntityType::Person);
        assert_eq!(EntityType::from_str_loose("company"), EntityType::Company);
        assert_eq!(EntityType::from_str_loose("product"), EntityType::Product);
        assert_eq!(EntityType::from_str_loose("transaction"), EntityType::Transaction);
        assert_eq!(EntityType::from_str_loose("healthcare"), EntityType::Healthcare);
        assert_eq!(EntityType::from_str_loose("patient"), EntityType::Healthcare);
        assert_eq!(EntityType::from_str_loose("custom_thing"), EntityType::Custom("custom_thing".to_string()));
    }

    #[test]
    fn test_entity_type_display_name() {
        assert_eq!(EntityType::Person.display_name(), "person");
        assert_eq!(EntityType::Company.display_name(), "company");
        assert_eq!(EntityType::Product.display_name(), "product");
        assert_eq!(EntityType::Transaction.display_name(), "transaction");
        assert_eq!(EntityType::Healthcare.display_name(), "healthcare");
        assert_eq!(EntityType::Custom("widgets".into()).display_name(), "widgets");
    }

    #[test]
    fn test_entity_type_to_entity_name() {
        assert_eq!(EntityType::Person.to_entity_name(), "person");
        assert_eq!(EntityType::Product.to_entity_name(), "product");
        assert_eq!(EntityType::Custom("my_entity".into()).to_entity_name(), "my_entity");
    }

    #[test]
    fn test_detect_signal_asin_columns() {
        assert_eq!(detect_signal("asin"), Some(IdentitySignal::Asin));
        assert_eq!(detect_signal("amazon_id"), Some(IdentitySignal::Asin));
        assert_eq!(detect_signal("amazon_asin"), Some(IdentitySignal::Asin));
        assert_eq!(detect_signal("amazon_sku"), Some(IdentitySignal::Asin));
        assert_eq!(detect_signal("ASIN"), Some(IdentitySignal::Asin));
    }

    #[test]
    fn test_detect_signal_from_values_asin() {
        let vals = vec!["B0CHCQ41RN", "B0D3JH5ZTN", "B0BN2KNQ97", "B0DCCS1Z95"];
        assert_eq!(detect_signal_from_values(&vals), Some(IdentitySignal::Asin));
    }

    #[test]
    fn test_gtin_check_digit_valid() {
        assert!(is_valid_gtin_check_digit("4006381333931"));
        assert!(is_valid_gtin_check_digit("036000291452"));
        assert!(is_valid_gtin_check_digit("00036000291452"));
        assert!(is_valid_gtin_check_digit("96385074"));
    }

    #[test]
    fn test_gtin_check_digit_invalid() {
        assert!(!is_valid_gtin_check_digit("4006381333932"));
        assert!(!is_valid_gtin_check_digit("036000291453"));
        assert!(!is_valid_gtin_check_digit("12345"));
        assert!(!is_valid_gtin_check_digit("ABC"));
    }

    #[test]
    fn test_gtin_restricted_prefix() {
        assert!(has_restricted_gtin_prefix("0200000000000"));
        assert!(has_restricted_gtin_prefix("0290000000000"));
        assert!(has_restricted_gtin_prefix("0400000000000"));
        assert!(has_restricted_gtin_prefix("2000000000000"));
        assert!(has_restricted_gtin_prefix("2990000000000"));
        assert!(!has_restricted_gtin_prefix("4006381333931"));
        assert!(!has_restricted_gtin_prefix("036000291452"));
    }

    #[test]
    fn test_sku_reclassified_to_gtin_by_check_digit() {
        let vals = vec!["4006381333931", "036000291452", "5901234123457", "4007249107183"];
        let value_signal = detect_signal_from_values(&vals);
        assert!(
            value_signal == Some(IdentitySignal::Gtin) || value_signal == Some(IdentitySignal::Upc),
            "Expected Gtin or Upc, got {:?}", value_signal
        );
    }

    #[test]
    fn test_sku_reclassified_to_asin() {
        let name_hint = detect_signal("sku");
        assert_eq!(name_hint, Some(IdentitySignal::Sku));
        let vals = vec!["B0CHCQ41RN", "B0D3JH5ZTN", "B0BN2KNQ97", "B0DCCS1Z95"];
        let value_signal = detect_signal_from_values(&vals);
        assert_eq!(value_signal, Some(IdentitySignal::Asin));
        let signal = match (&name_hint, &value_signal) {
            (Some(IdentitySignal::Sku), Some(IdentitySignal::Asin)) => &value_signal,
            _ => &name_hint,
        };
        assert_eq!(*signal, Some(IdentitySignal::Asin));
    }

    #[test]
    fn test_internal_skus_stay_as_sku() {
        let vals = vec!["STR-001", "STR-002", "STR-003", "RE29571"];
        let signal = detect_signal_from_values(&vals);
        assert_eq!(signal, Some(IdentitySignal::Sku));
    }

    #[test]
    fn test_supplier_sku_detected_as_sku_signal() {
        assert_eq!(detect_signal("supplier_sku"), Some(IdentitySignal::Sku));
        assert_eq!(detect_signal("vendor_sku"), Some(IdentitySignal::Sku));
        assert_eq!(detect_signal("product_sku"), Some(IdentitySignal::Sku));
        assert_eq!(detect_signal("seller_sku"), Some(IdentitySignal::Sku));
    }

    #[test]
    fn test_gtin_check_digit_with_dashes() {
        assert!(is_valid_gtin_check_digit("4-006381-333931"));
        assert!(is_valid_gtin_check_digit("036 000 291 452"));
        assert!(!is_valid_gtin_check_digit("4-006381-333932"));
    }

    #[test]
    fn test_gtin_check_digit_boundary_lengths() {
        assert!(!is_valid_gtin_check_digit(""));
        assert!(!is_valid_gtin_check_digit("5"));
        assert!(!is_valid_gtin_check_digit("9638507"));
        assert!(!is_valid_gtin_check_digit("963850741"));
        assert!(!is_valid_gtin_check_digit("03600029145"));
        assert!(!is_valid_gtin_check_digit("000360002914521"));
    }

    #[test]
    fn test_gtin_restricted_prefix_boundaries() {
        assert!(!has_restricted_gtin_prefix("0190000000000"));
        assert!(!has_restricted_gtin_prefix("0390000000000"));
        assert!(!has_restricted_gtin_prefix("1990000000000"));
        assert!(!has_restricted_gtin_prefix("0300000000000"));
        assert!(!has_restricted_gtin_prefix("0500000000000"));
        assert!(!has_restricted_gtin_prefix("3000000000000"));
        assert!(has_restricted_gtin_prefix("0250000000000"));
        assert!(has_restricted_gtin_prefix("0450000000000"));
        assert!(has_restricted_gtin_prefix("2500000000000"));
        assert!(!has_restricted_gtin_prefix("02"));
        assert!(!has_restricted_gtin_prefix(""));
    }

    #[test]
    fn test_gtin_detection_skipped_when_restricted_prefixes_dominate() {
        let vals = vec![
            "020 0000 00000 4",
            "029 0000 00000 5",
            "200 0000 00000 8",
            "400 6381 33393 1",
        ];
        let signal = detect_signal_from_values(&vals);
        assert_ne!(signal, Some(IdentitySignal::Gtin));
        assert_ne!(signal, Some(IdentitySignal::Upc));
    }

    #[test]
    fn test_asin_below_threshold_not_detected() {
        let vals = vec!["B0CHCQ41RN", "SKU-001", "SKU-002", "SKU-003", "SKU-004"];
        let signal = detect_signal_from_values(&vals);
        assert_ne!(signal, Some(IdentitySignal::Asin));
    }

    #[test]
    fn test_asin_wrong_prefix_not_detected() {
        let vals = vec!["B1CHCQ41RN", "B1D3JH5ZTN", "B1BN2KNQ97", "B1DCCS1Z95"];
        let signal = detect_signal_from_values(&vals);
        assert_ne!(signal, Some(IdentitySignal::Asin));
    }

    #[test]
    fn test_asin_wrong_length_not_detected() {
        let vals = vec!["B0CHCQ41R", "B0D3JH5ZT", "B0BN2KNQ9", "B0DCCS1Z9"];
        let signal = detect_signal_from_values(&vals);
        assert_ne!(signal, Some(IdentitySignal::Asin));
    }
}
