///|
/// A whitespace-delimited span of the raw query, with codepoint offsets into
/// the original string so a correction can be spliced in without disturbing
/// the surrounding text.
priv struct SuggestSpan {
  start : Int32
  end : Int32
  text : String
}

///|
/// Split a query into whitespace-delimited spans. Quoted regions are kept
/// intact as a single span so phrase content is never corrected.
fn suggest_spans(input : String) -> Array[SuggestSpan] {
  let chars = input.to_array()
  let spans : Array[SuggestSpan] = []
  let mut i = 0
  while i < chars.length() {
    if chars[i].is_whitespace() {
      i += 1
      continue
    }
    let start = i
    if chars[i] == '"' {
      // Consume through the closing quote (or end of input if unterminated).
      i += 1
      while i < chars.length() && chars[i] != '"' {
        i += 1
      }
      if i < chars.length() {
        i += 1
      }
    } else {
      while i < chars.length() && !chars[i].is_whitespace() {
        i += 1
      }
    }
    spans.push({ start, end: i, text: String::from_array(chars[start:i]), })
  }
  spans
}

///|
/// Total document frequency of a term across all fields, used to prefer
/// common words as correction candidates.
fn Index::suggest_freq(self : Index, term : String) -> Int32 {
  let mut total = 0
  for field in self.field_names() {
    total += self.doc_freq(field, term)
  }
  total
}

///|
/// Pick the best correction for an analyzed term: smallest edit distance
/// first, then highest document frequency (common words are more likely the
/// user's intent), then lexicographic order for determinism.
fn Index::suggest_best(self : Index, term : String, bound : Int32) -> String? {
  let candidates = self.fuzzy_terms(term, max_distance=bound)
  let mut best : FuzzyMatch? = None
  let mut best_freq = -1
  for m in candidates {
    // Exact matches (distance 0) are not corrections.
    if m.distance == 0 {
      continue
    }
    let freq = self.suggest_freq(m.term)
    let better = match best {
      None => true
      Some(b) =>
        m.distance < b.distance ||
        (
          m.distance == b.distance &&
          (freq > best_freq || (freq == best_freq && m.term < b.term))
        )
    }
    if better {
      best = Some(m)
      best_freq = freq
    }
  }
  best.map(fn(m) { m.term })
}

///|
/// Did-you-mean spelling correction. For each bare word in the query that is
/// absent from the dictionary but has a close match, return the query with
/// the first such word corrected; all other text is preserved verbatim.
/// Field-scoped terms (field:term), quoted phrases, operators (AND/OR/NOT),
/// prefixes (foo*), and parentheses are left untouched. Candidates are ranked
/// by document frequency so common words win over rare ones. Returns None
/// when nothing is correctable. `max_distance` is clamped to [0, 2].
pub fn Index::suggest(
  self : Index,
  query : String,
  max_distance~ : Int32 = 2,
) -> String? {
  let bound = clamp_max_distance(max_distance)
  if bound == 0 {
    return None
  }
  let chars = query.to_array()
  for span in suggest_spans(query) {
    let text = span.text
    // Skip quoted phrases, field-scoped terms, operators, prefixes, and
    // parenthesized groups — only bare words are eligible for correction.
    if text.has_prefix("\"") ||
      text.find(":") is Some(_) ||
      text.has_suffix("*") ||
      text.find("(") is Some(_) ||
      text.find(")") is Some(_) ||
      text == "AND" ||
      text == "OR" ||
      text == "NOT" {
      continue
    }
    let tokens = self.analyzer.analyze(text)
    // Only correct spans that analyze to exactly one term; multi-token
    // spans (e.g. CJK runs) are left as-is.
    if tokens.length() != 1 {
      continue
    }
    let term = tokens[0].term
    // Already a dictionary word: nothing to correct.
    if self.suggest_freq(term) > 0 {
      continue
    }
    match self.suggest_best(term, bound) {
      Some(replacement) => {
        let prefix = String::from_array(chars[:span.start])
        let suffix = String::from_array(chars[span.end:])
        return Some(prefix + replacement + suffix)
      }
      None => continue
    }
  }
  None
}