///|
/// A stable identity for an entry. The label is part of the identity so a
/// keyword may intentionally participate in more than one domain.
pub fn entry_key(entry : Entry) -> String {
  "\{entry.keyword}\u{1f}\{entry.label}"
}

///|
/// Return a human-readable identity that is safe to use in reports.
pub fn entry_display_key(entry : Entry) -> String {
  if entry.label.length() == 0 {
    entry.keyword
  } else {
    "\{entry.keyword} [\{entry.label}]"
  }
}

///|
/// Return a stable copy of entries stored in the lexicon.
pub fn Lexicon::entries(self : Lexicon) -> Array[Entry] {
  [
    for item in self.entries => item
  ]
}

///|
/// Validate one entry and return stable diagnostic codes.
///
/// The validator is deliberately non-raising so import pipelines can collect
/// every problem in one pass instead of failing on the first malformed row.
pub fn validate_entry(entry : Entry) -> Array[String] {
  let issues : Array[String] = []
  if entry.keyword.length() == 0 {
    issues.push("empty_keyword")
  }
  if entry.keyword.length() > 4096 {
    issues.push("keyword_too_long")
  }
  if entry.label.length() > 256 {
    issues.push("label_too_long")
  }
  if entry.keyword.has_prefix("\u{0}") || entry.label.has_prefix("\u{0}") {
    issues.push("nul_character")
  }
  issues
}

///|
/// Validate a dictionary and include the zero-based entry index in each issue.
pub fn validate_entries(entries : ArrayView[Entry]) -> Array[String] {
  let issues : Array[String] = []
  let seen : Map[String, Int] = Map([])
  for index, item in entries {
    for issue in validate_entry(item) {
      issues.push("\{index}:\{issue}")
    }
    let key = entry_key(item)
    match seen.get(key) {
      Some(previous) => issues.push("\{index}:duplicate_of_\{previous}")
      None => seen[key] = index
    }
  }
  issues
}

///|
/// True when two entries represent the same keyword and label.
pub fn entries_equal_identity(left : Entry, right : Entry) -> Bool {
  entry_key(left) == entry_key(right)
}

///|
/// Keep one entry for each keyword/label pair, retaining the highest weight.
pub fn deduplicate_entries(entries : ArrayView[Entry]) -> Array[Entry] {
  let output : Array[Entry] = []
  let positions : Map[String, Int] = Map([])
  for item in entries {
    let key = entry_key(item)
    match positions.get(key) {
      Some(index) =>
        if item.weight > output[index].weight {
          output[index] = item
        }
      None => {
        positions[key] = output.length()
        output.push(item)
      }
    }
  }
  output
}

///|
/// Merge two dictionaries while preserving first-seen order.
pub fn merge_entries(
  left : ArrayView[Entry],
  right : ArrayView[Entry],
) -> Array[Entry] {
  let all : Array[Entry] = []
  for item in left {
    all.push(item)
  }
  for item in right {
    all.push(item)
  }
  deduplicate_entries(all)
}

///|
/// Select entries that belong to one label.
pub fn entries_for_label(
  entries : ArrayView[Entry],
  label : String,
) -> Array[Entry] {
  [
    for item in entries if item.label == label => item
  ]
}

///|
/// Select entries whose weight reaches `minimum`.
pub fn entries_with_min_weight(
  entries : ArrayView[Entry],
  minimum : Int,
) -> Array[Entry] {
  [
    for item in entries if item.weight >= minimum => item
  ]
}

///|
/// Return entries grouped by label, retaining source order inside each group.
pub fn group_entries_by_label(
  entries : ArrayView[Entry],
) -> Map[String, Array[Entry]] {
  let groups : Map[String, Array[Entry]] = Map([])
  for item in entries {
    match groups.get(item.label) {
      Some(bucket) => bucket.push(item)
      None => groups[item.label] = [item]
    }
  }
  groups
}

///|
/// Count entries by label without compiling a trie.
pub fn count_entries_by_label(entries : ArrayView[Entry]) -> Map[String, Int] {
  let counts : Map[String, Int] = Map([])
  for item in entries {
    counts[item.label] = counts.get_or_default(item.label, 0) + 1
  }
  counts
}

///|
/// Return the number of distinct keyword/label identities.
pub fn distinct_entry_count(entries : ArrayView[Entry]) -> Int {
  let identities : Map[String, Bool] = Map([])
  for item in entries {
    identities[entry_key(item)] = true
  }
  identities.length()
}

///|
/// Return the longest keyword in a dictionary, or an empty string.
pub fn longest_keyword(entries : ArrayView[Entry]) -> String {
  let mut best = ""
  for item in entries {
    if item.keyword.length() > best.length() {
      best = item.keyword
    }
  }
  best
}

///|
/// Return the shortest keyword in a dictionary, or an empty string.
pub fn shortest_keyword(entries : ArrayView[Entry]) -> String {
  let mut best = ""
  for item in entries {
    if best.length() == 0 || item.keyword.length() < best.length() {
      best = item.keyword
    }
  }
  best
}

///|
/// Sort entries by keyword, label, then descending weight.
pub fn sort_entries(entries : ArrayView[Entry]) -> Array[Entry] {
  let output = entries.to_owned()
  output.sort_by(fn(left, right) {
    let keyword_order = left.keyword.compare(right.keyword)
    if keyword_order != 0 {
      keyword_order
    } else {
      let label_order = left.label.compare(right.label)
      if label_order != 0 {
        label_order
      } else {
        right.weight - left.weight
      }
    }
  })
  output
}

///|
/// Sort entries by descending weight, then longest keyword first.
pub fn sort_entries_by_weight(entries : ArrayView[Entry]) -> Array[Entry] {
  let output = entries.to_owned()
  output.sort_by(fn(left, right) {
    if left.weight != right.weight {
      right.weight - left.weight
    } else if left.keyword.length() != right.keyword.length() {
      right.keyword.length() - left.keyword.length()
    } else {
      left.keyword.compare(right.keyword)
    }
  })
  output
}

///|
/// A change set that can be applied to a base dictionary.
pub(all) struct DictionaryDelta {
  added : Array[Entry]
  removed : Array[Entry]
  updated : Array[Entry]
} derive(Debug, Eq, ToJson)

///|
/// Return an empty change set.
pub fn DictionaryDelta::empty() -> DictionaryDelta {
  { added: [], removed: [], updated: [] }
}

///|
/// Count all operations represented by a change set.
pub fn DictionaryDelta::change_count(self : DictionaryDelta) -> Int {
  self.added.length() + self.removed.length() + self.updated.length()
}

///|
/// True if applying the change set would leave the dictionary unchanged.
pub fn DictionaryDelta::is_empty(self : DictionaryDelta) -> Bool {
  self.change_count() == 0
}

///|
/// Compare dictionaries by keyword/label identity and weight.
pub fn diff_entries(
  before : ArrayView[Entry],
  after : ArrayView[Entry],
) -> DictionaryDelta {
  let old : Map[String, Entry] = Map([])
  let new : Map[String, Entry] = Map([])
  for item in before {
    old[entry_key(item)] = item
  }
  for item in after {
    new[entry_key(item)] = item
  }
  let delta = DictionaryDelta::empty()
  for item in before {
    let key = entry_key(item)
    match new.get(key) {
      None => delta.removed.push(item)
      Some(next) => if next.weight != item.weight { delta.updated.push(next) }
    }
  }
  for item in after {
    if !old.contains(entry_key(item)) {
      delta.added.push(item)
    }
  }
  delta
}

///|
/// Apply a dictionary change set and deduplicate the result.
pub fn apply_delta(
  base : ArrayView[Entry],
  delta : DictionaryDelta,
) -> Array[Entry] {
  let removed : Map[String, Bool] = Map([])
  for item in delta.removed {
    removed[entry_key(item)] = true
  }
  for item in delta.updated {
    removed[entry_key(item)] = true
  }
  let output : Array[Entry] = []
  for item in base {
    if !removed.contains(entry_key(item)) {
      output.push(item)
    }
  }
  for item in delta.updated {
    output.push(item)
  }
  for item in delta.added {
    output.push(item)
  }
  deduplicate_entries(output)
}

///|
/// Invert a change set so it can be used for rollback.
pub fn invert_delta(
  before : ArrayView[Entry],
  after : ArrayView[Entry],
  delta : DictionaryDelta,
) -> DictionaryDelta {
  let inverse = DictionaryDelta::empty()
  for item in delta.added {
    inverse.removed.push(item)
  }
  for item in delta.removed {
    inverse.added.push(item)
  }
  let old : Map[String, Entry] = Map([])
  for item in before {
    old[entry_key(item)] = item
  }
  for item in delta.updated {
    match old.get(entry_key(item)) {
      Some(previous) => inverse.updated.push(previous)
      None => ()
    }
  }
  ignore(after)
  inverse
}

///|
/// Return the frequency of each keyword length.
pub fn keyword_length_histogram(entries : ArrayView[Entry]) -> Map[Int, Int] {
  let histogram : Map[Int, Int] = Map([])
  for item in entries {
    let length = item.keyword.length()
    histogram[length] = histogram.get_or_default(length, 0) + 1
  }
  histogram
}

///|
/// Return the total weight of all entries.
pub fn total_entry_weight(entries : ArrayView[Entry]) -> Int {
  let mut total = 0
  for item in entries {
    total += item.weight
  }
  total
}

///|
/// Return a copy with weights clamped to the supplied inclusive range.
pub fn clamp_entry_weights(
  entries : ArrayView[Entry],
  minimum : Int,
  maximum : Int,
) -> Array[Entry] {
  let low = if minimum <= maximum { minimum } else { maximum }
  let high = if minimum <= maximum { maximum } else { minimum }
  [
    for item in entries => {
      let weight = if item.weight < low {
        low
      } else if item.weight > high {
        high
      } else {
        item.weight
      }
      { keyword: item.keyword, label: item.label, weight }
    }
  ]
}

///|
/// Remove entries with empty keywords while retaining all other fields.
pub fn remove_empty_entries(entries : ArrayView[Entry]) -> Array[Entry] {
  [
    for item in entries if item.keyword.length() > 0 => item
  ]
}

///|
/// Return only the first occurrence of each keyword, regardless of label.
pub fn unique_keywords(entries : ArrayView[Entry]) -> Array[Entry] {
  let seen : Map[String, Bool] = Map([])
  let output : Array[Entry] = []
  for item in entries {
    if !seen.contains(item.keyword) {
      seen[item.keyword] = true
      output.push(item)
    }
  }
  output
}

///|
/// Replace one keyword while preserving label and weight.
pub fn replace_keyword(entry : Entry, keyword : String) -> Entry {
  { keyword, label: entry.label, weight: entry.weight }
}

///|
/// Return a copy with a new label.
pub fn replace_label(entry : Entry, label : String) -> Entry {
  { keyword: entry.keyword, label, weight: entry.weight }
}

///|
/// Return a copy with a new weight.
pub fn replace_weight(entry : Entry, weight : Int) -> Entry {
  { keyword: entry.keyword, label: entry.label, weight }
}