//! Kanoniv Identity Language V2 Specification Types
//!
//! This module defines the core domain model for the identity resolution DSL.
//! Specs are parsed from YAML, validated, compiled to an IR, and executed by the engine.

use serde::{Deserialize, Deserializer, Serialize};
use std::collections::{BTreeMap, HashMap};
use schemars::JsonSchema;

// ============================================================================
// Top-Level Spec
// ============================================================================

/// The root structure of an identity specification.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IdentitySpec {
    /// API version (e.g., "kanoniv/v2")
    pub api_version: String,

    /// Identity version (e.g., "customer_v3.1")
    pub identity_version: String,

    /// Optional metadata about the spec
    #[serde(default)]
    pub metadata: Option<MetadataSpec>,

    /// The entity being resolved
    pub entity: EntitySpec,

    /// Data sources to reconcile.
    /// Accepts both array-style (`- name: crm ...`) and map-style (`crm: ...`) YAML.
    #[serde(deserialize_with = "deserialize_sources")]
    pub sources: Vec<SourceSpec>,

    /// External authoritative identifiers
    #[serde(default)]
    pub reference_identifiers: Option<Vec<ReferenceIdentifierSpec>>,

    /// Blocking strategy to reduce comparison space
    #[serde(default)]
    pub blocking: Option<BlockingSpec>,

    /// Matching rules (optional when using scoring.strategy: fellegi_sunter)
    #[serde(default)]
    pub rules: Vec<RuleSpec>,

    /// Survivorship rules for golden record construction
    #[serde(default)]
    pub survivorship: Option<SurvivorshipSpec>,

    /// Exclusion rules to prevent specific matches
    #[serde(default)]
    pub exclusions: Option<Vec<ExclusionSpec>>,

    /// Cross-entity relationships
    #[serde(default)]
    pub relations: Option<Vec<RelationSpec>>,

    /// Decision configuration
    pub decision: DecisionSpec,

    /// Governance policies
    #[serde(default)]
    pub governance: Option<GovernanceSpec>,
}

// ============================================================================
// Metadata
// ============================================================================

/// Optional metadata about the identity spec.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct MetadataSpec {
    pub name: Option<String>,
    pub description: Option<String>,
    pub owner: Option<String>,
    #[serde(default)]
    pub tags: Vec<String>,
}

// ============================================================================
// Entity
// ============================================================================

/// The entity being resolved.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EntitySpec {
    /// Entity name (e.g., "customer", "product")
    pub name: String,

    /// Optional description
    pub description: Option<String>,

    /// Compliance annotations
    #[serde(default)]
    pub compliance: Option<ComplianceSpec>,

    /// Hierarchy configuration
    #[serde(default)]
    pub hierarchy: Option<HierarchySpec>,
}

/// Compliance annotations for regulatory frameworks.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ComplianceSpec {
    /// Regulatory frameworks (e.g., ["hipaa", "gdpr"])
    #[serde(default)]
    pub frameworks: Vec<String>,

    /// Fields containing PII
    #[serde(default)]
    pub pii_fields: Vec<String>,

    /// Whether audit logging is required
    #[serde(default)]
    pub audit_required: bool,

    /// Data retention period in days
    pub retention_days: Option<u32>,
}

/// Hierarchy configuration for parent-child relationships.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HierarchySpec {
    /// Field containing parent reference
    pub parent_field: String,

    /// Maximum hierarchy depth
    #[serde(default = "default_hierarchy_depth")]
    pub depth: u8,

    /// Fields inherited down the hierarchy
    #[serde(default)]
    pub inheritance: Vec<String>,
}

fn default_hierarchy_depth() -> u8 {
    5
}

// ============================================================================
// Sources
// ============================================================================

/// Deserialize sources from either a YAML map (keys become names) or a YAML array.
///
/// Map-style:
/// ```yaml
/// sources:
///   crm:
///     adapter: csv
///     location: data/contacts.csv
/// ```
///
/// Array-style (original):
/// ```yaml
/// sources:
///   - name: crm
///     adapter: csv
///     location: data/contacts.csv
/// ```
fn deserialize_sources<'de, D>(deserializer: D) -> Result<Vec<SourceSpec>, D::Error>
where
    D: Deserializer<'de>,
{
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum SourcesFormat {
        Map(BTreeMap<String, SourceSpec>),
        Array(Vec<SourceSpec>),
    }

    match SourcesFormat::deserialize(deserializer)? {
        SourcesFormat::Map(map) => Ok(map
            .into_iter()
            .map(|(name, mut spec)| {
                spec.name = name;
                spec
            })
            .collect()),
        SourcesFormat::Array(vec) => Ok(vec),
    }
}

/// Adapter type for a data source.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AdapterType {
    Snowflake,
    Bigquery,
    Redshift,
    Databricks,
    Postgres,
    Pandas,
    Csv,
    Dbt,
    File,
    Webhook,
    Api,
}

/// Schema definition for a single field.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FieldSchema {
    #[serde(rename = "type")]
    pub field_type: String,
    #[serde(default)]
    pub pii: bool,
    #[serde(default)]
    pub nullable: bool,
}

/// Sampling configuration for a source.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SamplingSpec {
    pub strategy: SamplingStrategy,
    #[serde(default = "default_sample_rate")]
    pub rate: f64,
}

fn default_sample_rate() -> f64 {
    1.0
}

/// Sampling strategy.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SamplingStrategy {
    Random,
    Stratified,
    TimeBased,
}

/// A data source to reconcile.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SourceSpec {
    /// Unique name for this source.
    /// When using map-style sources, this is populated from the map key.
    #[serde(default)]
    pub name: String,

    // -- Adapter-first fields --

    /// Adapter type (e.g., postgres, snowflake, csv)
    #[serde(default)]
    pub adapter: Option<AdapterType>,

    /// Data location (e.g., "public.contacts", "s3://bucket/file.csv")
    #[serde(default)]
    pub location: Option<String>,

    /// Primary key field
    #[serde(default)]
    pub primary_key: Option<String>,

    /// Field-level schema declarations
    #[serde(default)]
    pub schema: Option<HashMap<String, FieldSchema>>,

    /// Sampling configuration
    #[serde(default)]
    pub sampling: Option<SamplingSpec>,

    /// Tags for governance policies
    #[serde(default)]
    pub tags: Vec<String>,

    // -- Legacy fields (backward compat) --

    /// Source system (legacy — use `adapter` instead)
    #[serde(default)]
    pub system: Option<String>,

    /// Table or collection name (legacy — use `location` instead)
    #[serde(default)]
    pub table: Option<String>,

    /// Primary key field (legacy — use `primary_key` instead)
    #[serde(default)]
    pub id: Option<String>,

    /// Attribute mappings (canonical_name -> source_field)
    #[serde(default)]
    pub attributes: HashMap<String, String>,

    /// Temporal configuration
    #[serde(default)]
    pub temporal: Option<TemporalSpec>,

    /// Freshness requirements
    #[serde(default)]
    pub freshness: Option<FreshnessSpec>,
}

impl SourceSpec {
    /// Resolve the adapter string, falling back to legacy `system` field.
    pub fn resolved_adapter(&self) -> String {
        self.adapter
            .as_ref()
            .map(|a| format!("{:?}", a).to_lowercase())
            .or_else(|| self.system.clone())
            .unwrap_or_else(|| "unknown".to_string())
    }

    /// Resolve the location, falling back to legacy `table` field.
    pub fn resolved_location(&self) -> String {
        self.location
            .clone()
            .or_else(|| self.table.clone())
            .unwrap_or_else(|| "unknown".to_string())
    }

    /// Resolve the primary key, falling back to legacy `id` field.
    pub fn resolved_primary_key(&self) -> String {
        self.primary_key
            .clone()
            .or_else(|| self.id.clone())
            .unwrap_or_else(|| "id".to_string())
    }

    /// Get PII fields declared in the schema.
    pub fn pii_fields(&self) -> Vec<String> {
        self.schema
            .as_ref()
            .map(|sch| {
                sch.iter()
                    .filter(|(_, v)| v.pii)
                    .map(|(k, _)| k.clone())
                    .collect()
            })
            .unwrap_or_default()
    }
}

/// Temporal matching configuration.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TemporalSpec {
    /// Field containing valid-from timestamp
    pub valid_from: String,

    /// Field containing valid-to timestamp (null = still active)
    pub valid_to: Option<String>,

    /// Temporal strategy
    #[serde(default)]
    pub strategy: TemporalStrategy,
}

pub use crate::ir::TemporalStrategy;

/// Freshness requirements for a source.
///
/// Supports two YAML formats:
/// - Standard: `max_age: "24h"` and `warn_after: "12h"`
/// - Hours alias: `max_age_hours: 24` and `warn_after_hours: 12` (converted to `"24h"` / `"12h"`)
///
/// If both `max_age` and `max_age_hours` are present, `max_age` takes precedence.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct FreshnessSpec {
    /// Maximum acceptable age (e.g., "24h")
    pub max_age: String,

    /// Warning threshold (e.g., "12h")
    pub warn_after: Option<String>,
}

impl<'de> Deserialize<'de> for FreshnessSpec {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct FreshnessRaw {
            max_age: Option<String>,
            max_age_hours: Option<u32>,
            warn_after: Option<String>,
            warn_after_hours: Option<u32>,
        }

        let raw = FreshnessRaw::deserialize(deserializer)?;

        let max_age = match (raw.max_age, raw.max_age_hours) {
            (Some(s), _) => s,
            (None, Some(h)) => format!("{}h", h),
            (None, None) => {
                return Err(serde::de::Error::custom(
                    "max_age or max_age_hours is required",
                ))
            }
        };

        let warn_after = match (raw.warn_after, raw.warn_after_hours) {
            (Some(s), _) => Some(s),
            (None, Some(h)) => Some(format!("{}h", h)),
            (None, None) => None,
        };

        Ok(FreshnessSpec { max_age, warn_after })
    }
}

// ============================================================================
// Reference Identifiers
// ============================================================================

/// External authoritative identifiers.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReferenceIdentifierSpec {
    /// Name of the identifier
    pub name: String,

    /// Type: external or internal
    #[serde(rename = "type")]
    pub id_type: ReferenceIdType,

    /// Authority (for external identifiers)
    pub authority: Option<String>,

    /// Field containing the identifier
    pub field: String,

    /// Match weight when this identifier matches
    #[serde(default = "default_weight")]
    pub match_weight: f64,

    /// Conditions for validity
    #[serde(default)]
    pub conditions: Vec<ConditionSpec>,
}

pub use crate::ir::ReferenceIdType;

/// A condition for reference identifier validity.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ConditionSpec {
    pub field: String,
    pub operator: String,
    pub value: Option<serde_json::Value>,
}

// ============================================================================
// Blocking
// ============================================================================

/// Blocking configuration to reduce comparison space.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BlockingSpec {
    /// Blocking strategy
    #[serde(default)]
    pub strategy: BlockingStrategy,

    /// Blocking keys
    #[serde(default)]
    pub keys: Vec<Vec<String>>,

    /// Fallback configuration
    #[serde(default)]
    pub fallback: Option<BlockingFallback>,
}

pub use crate::ir::BlockingStrategy;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BlockingFallback {
    #[serde(default)]
    pub enabled: bool,
    pub sample_rate: Option<f64>,
}

// ============================================================================
// Rules
// ============================================================================

/// Deserialize a `field` value from either a string or a `fields` array (takes the first element).
///
/// Accepts:
/// - `field: email` (string)
/// - `fields: [email]` (array, takes first element)
/// - `fields: email` (single string coerced)
fn deserialize_field_or_fields<'de, D>(deserializer: D) -> Result<String, D::Error>
where
    D: Deserializer<'de>,
{
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum FieldOrFields {
        Single(String),
        Multiple(Vec<String>),
    }

    match FieldOrFields::deserialize(deserializer)? {
        FieldOrFields::Single(s) => Ok(s),
        FieldOrFields::Multiple(v) => v
            .into_iter()
            .next()
            .ok_or_else(|| serde::de::Error::custom("fields array cannot be empty")),
    }
}

/// A matching rule.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RuleSpec {
    /// Exact field match
    Exact {
        name: String,
        #[serde(deserialize_with = "deserialize_field_or_fields", alias = "fields")]
        field: String,
        #[serde(default = "default_weight")]
        weight: f64,
        #[serde(default)]
        options: Option<ExactMatchOptions>,
    },

    /// Similarity-based match
    Similarity {
        name: String,
        #[serde(deserialize_with = "deserialize_field_or_fields", alias = "fields")]
        field: String,
        algorithm: SimilarityAlgorithm,
        threshold: f64,
        #[serde(default = "default_weight")]
        weight: f64,
        locale: Option<String>,
        #[serde(default)]
        options: Option<SimilarityOptions>,
        #[serde(default)]
        normalizer: Option<String>,
    },

    /// Numeric range match
    Range {
        name: String,
        #[serde(deserialize_with = "deserialize_field_or_fields", alias = "fields")]
        field: String,
        tolerance: f64,
        #[serde(default = "default_weight")]
        weight: f64,
        #[serde(default)]
        normalizer: Option<String>,
    },

    /// Composite rule (AND/OR)
    Composite {
        name: String,
        operator: CompositeOperator,
        children: Vec<RuleSpec>,
    },

    /// Machine learning based match
    Ml {
        name: String,
        model: String,
        features: Vec<String>,
        threshold: f64,
        #[serde(default = "default_weight")]
        weight: f64,
    },
}

fn default_weight() -> f64 {
    1.0
}

pub use crate::ir::SimilarityAlgorithm;

pub use crate::ir::CompositeOperator;

#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ExactMatchOptions {
    #[serde(default)]
    pub case_insensitive: bool,
    #[serde(default)]
    pub normalize: bool,
    #[serde(default)]
    pub normalizer: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct SimilarityOptions {
    #[serde(default)]
    pub transliterate: bool,
    #[serde(default)]
    pub ignore_titles: bool,
    #[serde(default)]
    pub ignore_suffixes: bool,
}

// ============================================================================
// Survivorship
// ============================================================================

/// Survivorship rules for golden record construction.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SurvivorshipSpec {
    /// Default strategy for all fields
    #[serde(default)]
    pub default: SurvivorshipStrategy,

    /// Field-specific overrides
    #[serde(default)]
    pub overrides: Vec<SurvivorshipOverride>,
}

pub use crate::ir::SurvivorshipStrategy;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SurvivorshipOverride {
    pub field: String,
    pub strategy: SurvivorshipStrategy,
    /// Priority list for SourcePriority strategy
    #[serde(default)]
    pub priority: Vec<String>,
    /// Aggregate function for Aggregate strategy
    pub function: Option<AggregateFunction>,
}

/// Aggregate function for survivorship.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AggregateFunction {
    Max,
    Min,
    Sum,
    Avg,
    Union,
    Intersection,
}

// ============================================================================
// Exclusions
// ============================================================================

/// Exclusion rules to prevent specific matches.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExclusionSpec {
    /// Exclude based on field value
    FieldValue {
        field: String,
        operator: String,
        value: serde_json::Value,
    },

    /// Explicit pair exclusion from a table
    ExplicitPair {
        source: String,
        left_id_field: String,
        right_id_field: String,
    },

    /// Prevent cross-tenant matching
    CrossTenant {
        #[serde(default)]
        enabled: bool,
    },
}

// ============================================================================
// Relations
// ============================================================================

/// Cross-entity relationship definition.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RelationSpec {
    /// Relationship name
    pub name: String,

    /// Source entity
    pub from_entity: String,

    /// Target entity
    pub to_entity: String,

    /// Join key field
    pub join_key: String,

    /// Cardinality
    pub cardinality: Cardinality,

    /// Whether to propagate matches across the relationship
    #[serde(default)]
    pub propagate_match: bool,
}

pub use crate::ir::Cardinality;

// ============================================================================
// Decision
// ============================================================================

/// Decision configuration for match resolution.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DecisionSpec {
    /// Scoring configuration.
    ///
    /// Supports two YAML formats:
    /// - Full object: `scoring: { method: weighted_sum }`
    /// - String shorthand: `scoring: weighted_sum` (other fields use defaults)
    #[serde(default, deserialize_with = "deserialize_scoring")]
    pub scoring: Option<ScoringSpec>,

    /// Thresholds for match/review/reject
    pub thresholds: ThresholdsSpec,

    /// Conflict resolution strategy
    #[serde(default)]
    pub conflict_strategy: ConflictStrategy,

    /// Review queue configuration
    #[serde(default)]
    pub review_queue: Option<ReviewQueueSpec>,

    /// Audit configuration
    #[serde(default)]
    pub audit: Option<AuditSpec>,

    /// Tie-breaking rules for equal-confidence pairs (determinism guarantee)
    #[serde(default)]
    pub tie_breaking: Option<Vec<TieBreaker>>,
}

/// Tie-breaking strategy for pairs with equal confidence scores.
/// Applied in order: first rule that breaks the tie wins.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TieBreaker {
    /// Prefer pairs where at least one rule matched exactly (score == 1.0)
    ExactMatchPriority,
    /// Prefer the pair with the lower entity_id (lexicographic UUID ordering)
    LowestEntityId,
    /// Prefer the pair with the earlier created_at / last_updated timestamp
    EarliestTimestamp,
}

/// Deserialize `scoring` from either a string shorthand or a full `ScoringSpec` object.
///
/// - `scoring: weighted_sum` -> `ScoringSpec { method: WeightedSum, ..Default::default() }`
/// - `scoring: { method: ml_ensemble, ... }` -> full `ScoringSpec`
/// - absent / null -> `None`
fn deserialize_scoring<'de, D>(deserializer: D) -> Result<Option<ScoringSpec>, D::Error>
where
    D: Deserializer<'de>,
{
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum ScoringFormat {
        Full(ScoringSpec),
        Shorthand(String),
    }

    let val = Option::<ScoringFormat>::deserialize(deserializer)?;
    match val {
        Some(ScoringFormat::Full(spec)) => Ok(Some(spec)),
        Some(ScoringFormat::Shorthand(s)) => {
            let method: ScoringMethod =
                serde_json::from_value(serde_json::Value::String(s))
                    .map_err(serde::de::Error::custom)?;
            Ok(Some(ScoringSpec {
                method,
                ..Default::default()
            }))
        }
        None => Ok(None),
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ScoringSpec {
    #[serde(default)]
    pub method: ScoringMethod,
    pub normalization: Option<String>,
    /// Configuration for MlEnsemble scoring method
    #[serde(default)]
    pub ml_ensemble: Option<MlEnsembleConfig>,
    /// Configuration for Custom expression-based scoring method
    #[serde(default)]
    pub custom: Option<CustomScoringConfig>,
    /// When set, overrides `method` with a probabilistic scoring strategy
    #[serde(default)]
    pub strategy: Option<ScoringStrategy>,
    /// Field definitions for Fellegi-Sunter strategy
    #[serde(default)]
    pub fields: Option<Vec<FsFieldSpec>>,
    /// Log-likelihood thresholds for Fellegi-Sunter strategy
    #[serde(default)]
    pub thresholds: Option<FsThresholds>,
    /// Training configuration for m/u probability estimation
    #[serde(default)]
    pub training: Option<FsTrainingConfig>,
}

/// Probabilistic scoring strategy (overrides `method` when set).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ScoringStrategy {
    FellegiSunter,
}

/// A field definition for Fellegi-Sunter probabilistic matching.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FsFieldSpec {
    /// Maps to entity field name
    pub name: String,
    /// Comparison function for this field
    pub comparator: FsComparator,
    /// Field importance multiplier
    #[serde(default = "default_weight")]
    pub weight: f64,
    /// P(agree | true match) — probability that matching records agree on this field
    pub m_probability: f64,
    /// P(agree | non-match) — probability that non-matching records agree on this field
    pub u_probability: f64,
}

/// Comparator functions for Fellegi-Sunter fields.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FsComparator {
    Exact,
    JaroWinkler,
    Levenshtein,
    Soundex,
    Metaphone,
    Cosine,
}

/// Log-likelihood thresholds for Fellegi-Sunter decision boundaries.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FsThresholds {
    /// Composite score >= this → Match
    #[serde(rename = "match")]
    pub match_threshold: f64,
    /// Composite score >= this → Review
    pub possible: f64,
    /// Composite score <= this → NoMatch
    pub non_match: f64,
}

/// Training configuration for estimating m/u probabilities via EM algorithm.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct FsTrainingConfig {
    /// "em" for unsupervised EM estimation, "manual" to use spec-specified m/u values
    #[serde(default = "default_training_method")]
    pub method: String,
    /// Maximum EM iterations
    #[serde(default = "default_max_iterations")]
    pub max_iterations: usize,
    /// EM convergence threshold
    #[serde(default = "default_convergence_threshold")]
    pub convergence_threshold: f64,
}

fn default_training_method() -> String { "manual".to_string() }
fn default_max_iterations() -> usize { 25 }
fn default_convergence_threshold() -> f64 { 0.001 }

/// Configuration for logistic regression (ML ensemble) scoring.
///
/// Applies a sigmoid function: sigma(bias + sum(coefficient_i * rule_score_i))
/// where sigma(x) = 1 / (1 + e^(-x))
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct MlEnsembleConfig {
    /// Learned coefficients per rule name
    #[serde(default)]
    pub coefficients: HashMap<String, f64>,
    /// Intercept (bias) term
    #[serde(default)]
    pub bias: f64,
}

/// Configuration for custom expression-based scoring.
///
/// The expression can reference rule names as variables (resolving to 0.0-1.0 raw scores)
/// and supports: +, -, *, /, parentheses, float literals, min(a,b), max(a,b).
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct CustomScoringConfig {
    /// Mathematical expression using rule names as variables
    #[serde(default)]
    pub expression: String,
}

pub use crate::ir::ScoringMethod;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ThresholdsSpec {
    /// Minimum score for automatic match
    #[serde(rename = "match")]
    pub match_threshold: f64,

    /// Minimum score for review queue
    #[serde(default)]
    pub review: Option<f64>,

    /// Maximum score for automatic rejection
    #[serde(default)]
    pub reject: Option<f64>,
}

pub use crate::ir::ConflictStrategy;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReviewQueueSpec {
    #[serde(default)]
    pub enabled: bool,
    pub webhook: Option<String>,
    pub sla_hours: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct AuditSpec {
    #[serde(default)]
    pub log_all_comparisons: bool,
    #[serde(default)]
    pub log_decisions: bool,
    #[serde(default)]
    pub log_conflicts: bool,
}

// ============================================================================
// Governance
// ============================================================================

/// Governance policies for source quality enforcement.
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct GovernanceSpec {
    /// Block reconciliation if any source is stale
    #[serde(default)]
    pub require_freshness: bool,
    /// Block reconciliation if schema drift is detected
    #[serde(default)]
    pub require_schema: bool,
    /// Every source must have these tags
    #[serde(default)]
    pub required_tags: Vec<String>,
    /// Log per-field Fellegi-Sunter weights in telemetry
    #[serde(default)]
    pub log_probabilities: bool,
    /// Block deploy if FS thresholds change without shadow run
    #[serde(default)]
    pub require_shadow_on_threshold_change: bool,
}

// ============================================================================
// Parsing
// ============================================================================

impl IdentitySpec {
    /// Parse an identity spec from YAML.
    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
        serde_yaml::from_str(yaml)
    }

    /// Serialize the spec to YAML.
    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
        serde_yaml::to_string(self)
    }
}

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

    #[test]
    fn test_parse_minimal_spec() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    adapter: postgres
    location: public.contacts
    primary_key: contact_id
    attributes:
      email: email_address

rules:
  - type: exact
    name: email_exact
    field: email
    weight: 1.0

decision:
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse YAML");
        assert_eq!(spec.api_version, "kanoniv/v2");
        assert_eq!(spec.entity.name, "customer");
        assert_eq!(spec.sources.len(), 1);
        assert_eq!(spec.rules.len(), 1);
        let src = &spec.sources[0];
        assert_eq!(src.adapter, Some(AdapterType::Postgres));
        assert_eq!(src.resolved_location(), "public.contacts");
        assert_eq!(src.resolved_primary_key(), "contact_id");
    }

    #[test]
    fn test_parse_legacy_spec_backward_compat() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email_address

rules:
  - type: exact
    name: email_exact
    field: email
    weight: 1.0

decision:
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse legacy YAML");
        let src = &spec.sources[0];
        assert_eq!(src.system, Some("salesforce".to_string()));
        assert_eq!(src.table, Some("contacts".to_string()));
        assert_eq!(src.id, Some("contact_id".to_string()));
        // Resolved methods should fall back to legacy fields
        assert_eq!(src.resolved_adapter(), "salesforce");
        assert_eq!(src.resolved_location(), "contacts");
        assert_eq!(src.resolved_primary_key(), "contact_id");
    }

    #[test]
    fn test_parse_full_adapter_spec() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: warehouse
    adapter: snowflake
    location: analytics.public.customers
    primary_key: customer_id
    schema:
      email:
        type: string
        pii: true
      name:
        type: string
        nullable: true
      age:
        type: integer
    sampling:
      strategy: random
      rate: 0.5
    tags:
      - production
      - pii-source
    attributes:
      email: email_address
      name: full_name

rules:
  - type: exact
    name: email_exact
    field: email
    weight: 1.0

decision:
  thresholds:
    match: 0.9

governance:
  require_freshness: true
  require_schema: true
  required_tags:
    - production
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse full adapter YAML");
        let src = &spec.sources[0];
        assert_eq!(src.adapter, Some(AdapterType::Snowflake));
        assert_eq!(src.resolved_location(), "analytics.public.customers");
        assert_eq!(src.resolved_primary_key(), "customer_id");
        assert_eq!(src.tags, vec!["production", "pii-source"]);
        assert_eq!(src.pii_fields(), vec!["email".to_string()]);

        let schema = src.schema.as_ref().unwrap();
        assert_eq!(schema.len(), 3);
        assert!(schema["email"].pii);
        assert!(!schema["name"].pii);
        assert!(schema["name"].nullable);

        let sampling = src.sampling.as_ref().unwrap();
        assert_eq!(sampling.strategy, SamplingStrategy::Random);
        assert!((sampling.rate - 0.5).abs() < f64::EPSILON);

        let gov = spec.governance.as_ref().unwrap();
        assert!(gov.require_freshness);
        assert!(gov.require_schema);
        assert_eq!(gov.required_tags, vec!["production"]);
    }

    #[test]
    fn test_parse_composite_rule() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: product

sources:
  - name: erp
    system: netsuite
    table: items
    id: item_id
    attributes:
      sku: sku_code
      name: description

rules:
  - type: composite
    name: sku_and_name
    operator: and
    children:
      - type: exact
        name: sku_match
        field: sku
        weight: 1.0
      - type: similarity
        name: name_match
        field: name
        algorithm: jaro_winkler
        threshold: 0.85
        weight: 0.7

decision:
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse YAML");
        match &spec.rules[0] {
            RuleSpec::Composite { name, operator, children } => {
                assert_eq!(name, "sku_and_name");
                assert_eq!(*operator, CompositeOperator::And);
                assert_eq!(children.len(), 2);
            }
            _ => panic!("Expected composite rule"),
        }
    }

    // ========================================================================
    // Scoring shorthand tests
    // ========================================================================

    #[test]
    fn test_scoring_string_shorthand() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email_address

rules:
  - type: exact
    name: email_exact
    field: email

decision:
  scoring: weighted_sum
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse scoring shorthand");
        let scoring = spec.decision.scoring.expect("scoring should be Some");
        assert_eq!(scoring.method, ScoringMethod::WeightedSum);
        assert!(scoring.normalization.is_none());
        assert!(scoring.ml_ensemble.is_none());
        assert!(scoring.custom.is_none());
    }

    #[test]
    fn test_scoring_full_object_still_works() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email_address

rules:
  - type: exact
    name: email_exact
    field: email

decision:
  scoring:
    method: ml_ensemble
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse scoring full object");
        let scoring = spec.decision.scoring.expect("scoring should be Some");
        assert_eq!(scoring.method, ScoringMethod::MlEnsemble);
    }

    #[test]
    fn test_scoring_absent_is_none() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email_address

rules:
  - type: exact
    name: email_exact
    field: email

decision:
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse spec without scoring");
        assert!(spec.decision.scoring.is_none());
    }

    // ========================================================================
    // Freshness alias tests
    // ========================================================================

    #[test]
    fn test_freshness_max_age_hours_alias() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email_address
    freshness:
      max_age_hours: 24
      warn_after_hours: 12

rules:
  - type: exact
    name: email_exact
    field: email

decision:
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse freshness hours alias");
        let freshness = spec.sources[0].freshness.as_ref().expect("freshness should be Some");
        assert_eq!(freshness.max_age, "24h");
        assert_eq!(freshness.warn_after.as_deref(), Some("12h"));
    }

    #[test]
    fn test_freshness_standard_format_still_works() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email_address
    freshness:
      max_age: "24h"
      warn_after: "12h"

rules:
  - type: exact
    name: email_exact
    field: email

decision:
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse standard freshness");
        let freshness = spec.sources[0].freshness.as_ref().expect("freshness should be Some");
        assert_eq!(freshness.max_age, "24h");
        assert_eq!(freshness.warn_after.as_deref(), Some("12h"));
    }

    #[test]
    fn test_freshness_max_age_hours_without_warn() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email_address
    freshness:
      max_age_hours: 48

rules:
  - type: exact
    name: email_exact
    field: email

decision:
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse freshness without warn");
        let freshness = spec.sources[0].freshness.as_ref().expect("freshness should be Some");
        assert_eq!(freshness.max_age, "48h");
        assert!(freshness.warn_after.is_none());
    }

    // ========================================================================
    // Fellegi-Sunter spec parsing tests
    // ========================================================================

    #[test]
    fn test_parse_fellegi_sunter_spec() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: fs_v1

entity:
  name: customer

sources:
  - name: crm
    system: test
    table: contacts
    id: id
    attributes:
      email: email

decision:
  scoring:
    strategy: fellegi_sunter
    fields:
      - name: email
        comparator: exact
        weight: 2.0
        m_probability: 0.99
        u_probability: 0.001
      - name: first_name
        comparator: jaro_winkler
        weight: 1.0
        m_probability: 0.92
        u_probability: 0.02
    thresholds:
      match: 6.0
      possible: 2.0
      non_match: -4.0
  thresholds:
    match: 0.9

governance:
  log_probabilities: true
  require_shadow_on_threshold_change: true
"#;
        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse FS spec");
        let scoring = spec.decision.scoring.as_ref().unwrap();
        assert_eq!(scoring.strategy, Some(ScoringStrategy::FellegiSunter));

        let fields = scoring.fields.as_ref().unwrap();
        assert_eq!(fields.len(), 2);
        assert_eq!(fields[0].name, "email");
        assert_eq!(fields[0].comparator, FsComparator::Exact);
        assert!((fields[0].weight - 2.0).abs() < f64::EPSILON);
        assert!((fields[0].m_probability - 0.99).abs() < f64::EPSILON);
        assert!((fields[0].u_probability - 0.001).abs() < f64::EPSILON);

        let thresholds = scoring.thresholds.as_ref().unwrap();
        assert!((thresholds.match_threshold - 6.0).abs() < f64::EPSILON);
        assert!((thresholds.possible - 2.0).abs() < f64::EPSILON);
        assert!((thresholds.non_match - -4.0).abs() < f64::EPSILON);

        let gov = spec.governance.as_ref().unwrap();
        assert!(gov.log_probabilities);
        assert!(gov.require_shadow_on_threshold_change);
    }

    #[test]
    fn test_parse_fellegi_sunter_with_em_training() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: fs_em_v1

entity:
  name: customer

sources:
  - name: crm
    system: test
    table: contacts
    id: id
    attributes:
      email: email

decision:
  scoring:
    strategy: fellegi_sunter
    training:
      method: em
      max_iterations: 50
      convergence_threshold: 0.0001
    fields:
      - name: email
        comparator: exact
        weight: 1.0
        m_probability: 0.9
        u_probability: 0.1
    thresholds:
      match: 5.0
      possible: 2.0
      non_match: -3.0
  thresholds:
    match: 0.9
"#;
        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse FS spec with EM");
        let scoring = spec.decision.scoring.as_ref().unwrap();
        let training = scoring.training.as_ref().unwrap();
        assert_eq!(training.method, "em");
        assert_eq!(training.max_iterations, 50);
        assert!((training.convergence_threshold - 0.0001).abs() < f64::EPSILON);
    }

    #[test]
    fn test_freshness_max_age_takes_precedence_over_hours() {
        let yaml = r#"
api_version: kanoniv/v2
identity_version: test_v1

entity:
  name: customer

sources:
  - name: crm
    system: salesforce
    table: contacts
    id: contact_id
    attributes:
      email: email_address
    freshness:
      max_age: "30m"
      max_age_hours: 24

rules:
  - type: exact
    name: email_exact
    field: email

decision:
  thresholds:
    match: 0.9
"#;

        let spec = IdentitySpec::from_yaml(yaml).expect("Failed to parse freshness precedence");
        let freshness = spec.sources[0].freshness.as_ref().expect("freshness should be Some");
        assert_eq!(freshness.max_age, "30m", "max_age string should take precedence over max_age_hours");
    }
}
