///|
/// Return a trimmed identifier, preserving case and semantic content.
pub fn normalize_identifier(value : String) -> String {
  value.trim().to_owned()
}

///|
/// Return a trimmed feature record without changing numeric values.
pub fn normalize_feature(feature : Feature) -> Feature {
  {
    id: normalize_identifier(feature.id),
    kind: feature.kind,
    nominal: feature.nominal,
    lower: feature.lower,
    upper: feature.upper,
    datum: feature.datum,
  }
}

///|
/// Return a trimmed device record without changing capability values.
pub fn normalize_device(device : Device) -> Device {
  {
    id: normalize_identifier(device.id),
    accuracy: device.accuracy,
    reach: device.reach,
    supported_kinds: device.supported_kinds,
  }
}

///|
/// Return a trimmed observation record without changing measured value.
pub fn normalize_observation(observation : Observation) -> Observation {
  {
    feature_id: normalize_identifier(observation.feature_id),
    value: observation.value,
    sample_count: observation.sample_count,
  }
}

///|
/// Normalize all identifiers in a catalog while preserving array order.
pub fn normalize_catalog(catalog : Catalog) -> Catalog {
  let features = []
  for feature in catalog.features {
    features.push(normalize_feature(feature))
  }
  let devices = []
  for device in catalog.devices {
    devices.push(normalize_device(device))
  }
  { features, devices }
}

///|
/// Return whether two features differ in any field.
pub fn normalization_changed(before : Feature, after : Feature) -> Bool {
  before.id != after.id ||
  before.kind != after.kind ||
  before.nominal != after.nominal ||
  before.lower != after.lower ||
  before.upper != after.upper ||
  before.datum != after.datum
}

///|
/// Return whether feature identifiers are unique and non-blank.
pub fn ids_are_unique(features : Array[Feature]) -> Bool {
  let seen = []
  let mut result = true
  for feature in features {
    if feature.id.trim().length() == 0 {
      result = false
    }
    for id in seen {
      if id == feature.id {
        result = false
      }
    }
    seen.push(feature.id)
  }
  result
}

///|
/// Return the canonical CSV line for a feature.
pub fn canonical_feature_line(feature : Feature) -> String {
  feature.id +
  "," +
  feature_kind_name(feature.kind) +
  "," +
  feature.nominal.to_string() +
  "," +
  feature.lower.to_string() +
  "," +
  feature.upper.to_string() +
  "," +
  feature.datum.to_string()
}

///|
/// Return the canonical CSV line for an observation.
pub fn canonical_observation_line(observation : Observation) -> String {
  observation.feature_id +
  "," +
  observation.value.to_string() +
  "," +
  observation.sample_count.to_string()
}

///|
/// Return a stable part identifier suitable for report headers.
pub fn canonical_part_id(part_id : String) -> String {
  normalize_identifier(part_id)
}

///|
/// Normalize a list of observations while preserving source order.
pub fn normalize_observations(
  observations : Array[Observation],
) -> Array[Observation] {
  let result = []
  for observation in observations {
    result.push(normalize_observation(observation))
  }
  result
}

///|
/// Count features whose identifiers change during normalization.
pub fn normalization_change_count(features : Array[Feature]) -> Int {
  let mut result = 0
  for feature in features {
    if normalization_changed(feature, normalize_feature(feature)) {
      result += 1
    }
  }
  result
}

///|
/// Count devices whose identifiers change during normalization.
pub fn device_normalization_change_count(devices : Array[Device]) -> Int {
  let mut result = 0
  for device in devices {
    let normalized = normalize_device(device)
    if normalized.id != device.id {
      result += 1
    }
  }
  result
}

///|
/// Return whether all identifiers are already canonical.
pub fn is_canonical_catalog(catalog : Catalog) -> Bool {
  normalization_change_count(catalog.features) == 0 &&
  device_normalization_change_count(catalog.devices) == 0
}