///|
struct SearchDocument {
  identifier_value : String
  source_value : String
  candidate_value : MatchCandidate
}

///|
/// Mutable validated builder for a reusable search index.
pub struct SearchIndexBuilder {
  document_values : Array[SearchDocument]
}

///|
/// Immutable collection of precomputed Pinyin match candidates.
pub struct SearchIndex {
  document_values : Array[SearchDocument]
}

///|
/// Query limits and minimum accepted score.
pub struct SearchOptions {
  limit_value : Int
  minimum_score_value : Double
} derive(Eq, Debug)

///|
/// One ranked hit with source text and score evidence.
pub struct SearchHit {
  identifier_value : String
  source_value : String
  explanation_value : MatchExplanation
  source_position : Int
} derive(Eq, Debug)

///|
fn copy_search_documents(
  values : Array[SearchDocument],
) -> Array[SearchDocument] {
  let output : Array[SearchDocument] = []
  for value in values {
    output.push(value)
  }
  output
}

///|
fn valid_search_identifier(value : String) -> Bool {
  if value.length() == 0 {
    return false
  }
  for code in text_code_points(value) {
    if code == 10 || code == 13 {
      return false
    }
  }
  true
}

///|
pub fn SearchIndexBuilder::new() -> SearchIndexBuilder {
  { document_values: [] }
}

///|
/// Converts and precomputes one document before inserting it.
pub fn SearchIndexBuilder::add(
  self : SearchIndexBuilder,
  identifier : String,
  source : String,
  lexicon : Lexicon,
  options : ConvertOptions,
) -> Result[Unit, PinyinError] {
  if !valid_search_identifier(identifier) {
    let reason = if identifier.length() == 0 {
      "empty_identifier"
    } else {
      "line_break_in_identifier"
    }
    return Err(InvalidSearchDocument(identifier, reason))
  }
  for document in self.document_values {
    if document.identifier_value == identifier {
      return Err(InvalidSearchDocument(identifier, "duplicate_identifier"))
    }
  }
  let converted = match convert_text(source, lexicon, options) {
    Err(error) => return Err(error)
    Ok(value) => value
  }
  self.document_values.push({
    identifier_value: identifier,
    source_value: source,
    candidate_value: match_candidate(identifier, converted),
  })
  Ok(())
}

///|
pub fn SearchIndexBuilder::length(self : SearchIndexBuilder) -> Int {
  self.document_values.length()
}

///|
/// Freezes the current insertion order into an immutable index.
pub fn SearchIndexBuilder::build(self : SearchIndexBuilder) -> SearchIndex {
  { document_values: copy_search_documents(self.document_values) }
}

///|
pub fn search_options(
  limit : Int,
  minimum_score : Double,
) -> Result[SearchOptions, PinyinError] {
  if limit <= 0 {
    return Err(InvalidSearchOptions("limit", limit.to_string()))
  }
  if minimum_score.is_nan() || minimum_score.is_inf() {
    return Err(InvalidSearchOptions("minimum_score", "not_finite"))
  }
  if minimum_score < 0.0 || minimum_score > 1.0 {
    return Err(InvalidSearchOptions("minimum_score", "out_of_range"))
  }
  Ok({ limit_value: limit, minimum_score_value: minimum_score })
}

///|
pub fn default_search_options() -> SearchOptions {
  search_options(20, 0.0).unwrap()
}

///|
/// Queries precomputed candidates with stable caller-order tie breaking.
pub fn SearchIndex::search(
  self : SearchIndex,
  query : String,
  options : SearchOptions,
  profile : FuzzyProfile,
) -> Result[Array[SearchHit], PinyinError] {
  if normalize_literal(query).length() == 0 {
    return Err(InvalidSearchQuery("empty_normalized_query"))
  }
  let candidates : Array[MatchCandidate] = []
  for document in self.document_values {
    candidates.push(document.candidate_value)
  }
  let ranked = rank_matches(query, candidates, profile)
  let hits : Array[SearchHit] = []
  for ranked_item in ranked {
    if hits.length() >= options.limit_value {
      break
    }
    if ranked_item.score() < options.minimum_score_value {
      continue
    }
    let position = ranked_item.original_position()
    let document = self.document_values[position]
    hits.push({
      identifier_value: document.identifier_value,
      source_value: document.source_value,
      explanation_value: ranked_item.explanation(),
      source_position: position,
    })
  }
  Ok(hits)
}

///|
pub fn SearchIndex::length(self : SearchIndex) -> Int {
  self.document_values.length()
}

///|
pub fn SearchOptions::limit(self : SearchOptions) -> Int {
  self.limit_value
}

///|
pub fn SearchOptions::minimum_score(self : SearchOptions) -> Double {
  self.minimum_score_value
}

///|
pub fn SearchHit::identifier(self : SearchHit) -> String {
  self.identifier_value
}

///|
pub fn SearchHit::source(self : SearchHit) -> String {
  self.source_value
}

///|
pub fn SearchHit::score(self : SearchHit) -> Double {
  self.explanation_value.score()
}

///|
pub fn SearchHit::explanation(self : SearchHit) -> MatchExplanation {
  self.explanation_value
}

///|
pub fn SearchHit::original_position(self : SearchHit) -> Int {
  self.source_position
}