///|
fn invalid_match_number(value : Double) -> Bool {
  value.is_nan() || value.is_inf()
}

///|
/// Returns a copy with a different decision threshold.
pub fn MatchConfig::with_threshold(
  self : MatchConfig,
  threshold : Double,
) -> MatchConfig {
  {
    normalization: self.normalization,
    encoders: self.encoders.copy(),
    metric: self.metric,
    string_weight: self.string_weight,
    threshold,
    token_policy: self.token_policy,
    unmatched_token_penalty: self.unmatched_token_penalty,
  }
}

///|
/// Returns a copy with a different string-similarity metric.
pub fn MatchConfig::with_metric(
  self : MatchConfig,
  metric : SimilarityMetric,
) -> MatchConfig {
  {
    normalization: self.normalization,
    encoders: self.encoders.copy(),
    metric,
    string_weight: self.string_weight,
    threshold: self.threshold,
    token_policy: self.token_policy,
    unmatched_token_penalty: self.unmatched_token_penalty,
  }
}

///|
/// Returns a copy with a different string component weight.
pub fn MatchConfig::with_string_weight(
  self : MatchConfig,
  string_weight : Double,
) -> MatchConfig {
  {
    normalization: self.normalization,
    encoders: self.encoders.copy(),
    metric: self.metric,
    string_weight,
    threshold: self.threshold,
    token_policy: self.token_policy,
    unmatched_token_penalty: self.unmatched_token_penalty,
  }
}

///|
/// Returns a copy with different token assignment and penalty settings.
pub fn MatchConfig::with_token_settings(
  self : MatchConfig,
  token_policy : TokenPolicy,
  unmatched_token_penalty : Double,
) -> MatchConfig {
  {
    normalization: self.normalization,
    encoders: self.encoders.copy(),
    metric: self.metric,
    string_weight: self.string_weight,
    threshold: self.threshold,
    token_policy,
    unmatched_token_penalty,
  }
}

///|
/// Returns a copy with a separate encoder array owned by the new config.
pub fn MatchConfig::with_encoders(
  self : MatchConfig,
  encoders : Array[EncoderWeight],
) -> MatchConfig {
  {
    normalization: self.normalization,
    encoders: encoders.copy(),
    metric: self.metric,
    string_weight: self.string_weight,
    threshold: self.threshold,
    token_policy: self.token_policy,
    unmatched_token_penalty: self.unmatched_token_penalty,
  }
}

///|
/// Returns a copy with a different normalization policy.
pub fn MatchConfig::with_normalization(
  self : MatchConfig,
  normalization : NormalizationConfig,
) -> MatchConfig {
  {
    normalization,
    encoders: self.encoders.copy(),
    metric: self.metric,
    string_weight: self.string_weight,
    threshold: self.threshold,
    token_policy: self.token_policy,
    unmatched_token_penalty: self.unmatched_token_penalty,
  }
}

///|
fn validate_match_metric(metric : SimilarityMetric) -> Result[Unit, MatchError] {
  match metric {
    JaroWinkler(scaling) =>
      if invalid_match_number(scaling) || scaling < 0.0 || scaling > 0.25 {
        Err(InvalidSimilarityMetric(InvalidWinklerScaling(scaling)))
      } else {
        Ok(())
      }
    Dice(size) =>
      if size < 1 || size > 64 {
        Err(InvalidSimilarityMetric(InvalidNGramSize(size)))
      } else {
        Ok(())
      }
    Levenshtein | Jaro => Ok(())
  }
}

///|
/// Validates every nested policy and score parameter before matching.
pub fn validate_match_config(config : MatchConfig) -> Result[Unit, MatchError] {
  match validate_normalization_config(config.normalization) {
    Err(error) => return Err(InvalidNormalizationConfig(error))
    Ok(_) => ()
  }
  if invalid_match_number(config.threshold) ||
    config.threshold < 0.0 ||
    config.threshold > 1.0 {
    return Err(InvalidThreshold(config.threshold))
  }
  if invalid_match_number(config.string_weight) || config.string_weight < 0.0 {
    return Err(InvalidStringWeight(config.string_weight))
  }
  if invalid_match_number(config.unmatched_token_penalty) ||
    config.unmatched_token_penalty < 0.0 ||
    config.unmatched_token_penalty > 1.0 {
    return Err(InvalidUnmatchedTokenPenalty(config.unmatched_token_penalty))
  }
  match validate_match_metric(config.metric) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  let mut positive_components = if config.string_weight > 0.0 { 1 } else { 0 }
  for index = 0; index < config.encoders.length(); index = index + 1 {
    let encoder = config.encoders[index]
    if invalid_match_number(encoder.weight) || encoder.weight < 0.0 {
      return Err(InvalidEncoderWeight(encoder.algorithm, encoder.weight))
    }
    if encoder.weight > 0.0 {
      positive_components = positive_components + 1
    }
    for earlier = 0; earlier < index; earlier = earlier + 1 {
      if config.encoders[earlier].algorithm == encoder.algorithm {
        return Err(DuplicateEncoder(encoder.algorithm))
      }
    }
  }
  if positive_components == 0 {
    Err(NoPositiveComponents)
  } else {
    Ok(())
  }
}

///|
/// Returns a high-threshold whole-name strategy.
pub fn conservative_match_config() -> MatchConfig {
  {
    normalization: name_normalization_config(),
    encoders: [
      { algorithm: DoubleMetaphone, weight: 0.45 },
      { algorithm: Nysiis, weight: 0.20 },
    ],
    metric: JaroWinkler(0.1),
    string_weight: 0.35,
    threshold: 0.88,
    token_policy: WholeInput,
    unmatched_token_penalty: 0.5,
  }
}

///|
/// Returns the default mixed phonetic and token-aware strategy.
pub fn balanced_match_config() -> MatchConfig {
  {
    normalization: name_normalization_config(),
    encoders: [
      { algorithm: DoubleMetaphone, weight: 0.30 },
      { algorithm: Metaphone, weight: 0.15 },
      { algorithm: Nysiis, weight: 0.15 },
    ],
    metric: JaroWinkler(0.1),
    string_weight: 0.40,
    threshold: 0.78,
    token_policy: BestTokenPairs,
    unmatched_token_penalty: 0.5,
  }
}

///|
/// Returns a lower-threshold strategy intended to retain more candidates.
pub fn recall_match_config() -> MatchConfig {
  {
    normalization: name_normalization_config(),
    encoders: [
      { algorithm: DoubleMetaphone, weight: 0.25 },
      { algorithm: Soundex, weight: 0.15 },
      { algorithm: Caverphone2, weight: 0.15 },
    ],
    metric: Dice(2),
    string_weight: 0.45,
    threshold: 0.67,
    token_policy: BestTokenPairs,
    unmatched_token_penalty: 0.35,
  }
}