///|
/// Classification of a SINGLE declared path, not whole-contract inclusion.
pub(all) enum ConfigContractEvolutionImpact {
  Equivalent
  Tightened
  Relaxed
  Incomparable
  Unknown
} derive(Eq, Debug)

///|
pub extend ConfigContractEvolutionImpact with Eq::{not_equal, equal}

///|
pub extend ConfigContractEvolutionImpact with Debug::{to_repr}

///|
/// Value-free diagnostic. Reason is a stable code, never a configuration value.
pub struct ConfigContractEvolutionChange {
  path : ConfigPath
  impact : ConfigContractEvolutionImpact
  reason : String
}

///|
pub fn ConfigContractEvolutionChange::path(
  self : ConfigContractEvolutionChange,
) -> ConfigPath {
  self.path
}

///|
pub fn ConfigContractEvolutionChange::impact(
  self : ConfigContractEvolutionChange,
) -> ConfigContractEvolutionImpact {
  self.impact
}

///|
pub fn ConfigContractEvolutionChange::reason(
  self : ConfigContractEvolutionChange,
) -> String {
  self.reason
}

///|
fn evolution_subset_impact(
  old_in_new : Bool,
  new_in_old : Bool,
) -> ConfigContractEvolutionImpact {
  match (old_in_new, new_in_old) {
    (true, true) => Equivalent
    (false, true) => Tightened
    (true, false) => Relaxed
    (false, false) => Incomparable
  }
}

///|
fn evolution_lower_included(inner : Double?, outer : Double?) -> Bool {
  match (inner, outer) {
    (_, None) => true
    (None, Some(_)) => false
    (Some(a), Some(b)) => a >= b
  }
}

///|
fn evolution_upper_included(inner : Double?, outer : Double?) -> Bool {
  match (inner, outer) {
    (_, None) => true
    (None, Some(_)) => false
    (Some(a), Some(b)) => a <= b
  }
}

///|
fn evolution_path_overlap(a : ConfigPath, b : ConfigPath) -> Bool {
  let limit = if a.length() < b.length() { a.length() } else { b.length() }
  for index in 0.. (ConfigContractEvolutionImpact, String) {
  if old.when is Some(_) || new.when is Some(_) {
    return (Unknown, "conditional_rule")
  }
  if old.default_value != new.default_value {
    return (Unknown, "default_changed")
  }
  let required_changed = old.required != new.required
  let allowed_changed = old.allowed_values != new.allowed_values
  let bounds_changed = old.minimum != new.minimum || old.maximum != new.maximum
  if old.expected_kind != new.expected_kind || old.integer != new.integer {
    return (Unknown, "type_or_integer_changed")
  }
  let dimensions = (if required_changed { 1 } else { 0 }) +
    (if allowed_changed { 1 } else { 0 }) +
    (if bounds_changed { 1 } else { 0 })
  if dimensions != 1 {
    return (Unknown, "combined_constraints")
  }
  if required_changed {
    if old.default_value is Some(_) {
      return (Equivalent, "required_with_default")
    }
    return (if new.required { Tightened } else { Relaxed }, "required")
  }
  if allowed_changed {
    match (old.allowed_values, new.allowed_values) {
      (Some(a), Some(b)) =>
        return (
          evolution_subset_impact(
            a.all(v => b.contains(v)),
            b.all(v => a.contains(v)),
          ),
          "allowed_values",
        )
      _ => return (Unknown, "unbounded_allowed_values")
    }
  }
  // Bounds only exist on explicit number rules. Integer intervals are compared
  // after rounding inward, not by literal endpoint movement.
  let old_min = old.minimum.map(v => if old.integer { v.ceil() } else { v })
  let new_min = new.minimum.map(v => if new.integer { v.ceil() } else { v })
  let old_max = old.maximum.map(v => if old.integer { v.floor() } else { v })
  let new_max = new.maximum.map(v => if new.integer { v.floor() } else { v })
  if old.allowed_values is Some(_) {
    // Explicit enum values were validated against each interval at parse time;
    // changing bounds cannot remove any value from the unchanged allowed set.
    return (Equivalent, "bounds_with_allowed_values")
  }
  (
    evolution_subset_impact(
      evolution_lower_included(old_min, new_min) &&
      evolution_upper_included(old_max, new_max),
      evolution_lower_included(new_min, old_min) &&
      evolution_upper_included(new_max, old_max),
    ),
    "numeric_bounds",
  )
}

///|
/// Describe syntactically changed rules in lexical path order. Tightened means
/// the new rule accepts a strict subset of the old rule's inputs at this path;
/// Relaxed means a strict superset. Incomparable means neither includes the other.
/// Conditions, overlapping paths and unsupported combinations remain Unknown.
/// This is not a release gate or proof of whole-contract compatibility.
pub fn ConfigConsumerContract::evolution_to(
  self : ConfigConsumerContract,
  later : ConfigConsumerContract,
) -> Array[ConfigContractEvolutionChange] {
  let before : Map[String, ConfigConsumerRule] = Map([])
  let after : Map[String, ConfigConsumerRule] = Map([])
  for rule in self.rules {
    before[rule.path.to_string()] = rule
  }
  for rule in later.rules {
    after[rule.path.to_string()] = rule
  }
  let paths : Map[String, ConfigPath] = Map([])
  for rule in self.rules {
    paths[rule.path.to_string()] = rule.path
  }
  for rule in later.rules {
    paths[rule.path.to_string()] = rule.path
  }
  let changes : Array[ConfigContractEvolutionChange] = []
  for key, path in paths {
    let old = before.get(key)
    let new = after.get(key)
    if old == new {
      continue
    }
    let mut overlapping = false
    for _, other in paths {
      if evolution_path_overlap(path, other) {
        overlapping = true
      }
    }
    let (impact, reason) = if overlapping {
      (Unknown, "overlapping_paths")
    } else {
      match (old, new) {
        (Some(a), Some(b)) => evolution_rule_change(a, b)
        (None, Some(_)) => (Unknown, "rule_added")
        (Some(_), None) => (Unknown, "rule_removed")
        _ => abort("unreachable")
      }
    }
    changes.push({ path, impact, reason, })
  }
  changes.sort_by((a, b) => {
    a.path.to_string().lexical_compare(b.path.to_string())
  })
  changes
}