///|
/// One extracted keyword and its corpus-local frequency.
pub struct Keyword {
  term : String
  count : Int
  frequency : Double
} derive(Eq, Debug)

///|
/// Query-focused extractive summary.
pub struct TextSummary {
  text : String
  original_sentences : Int
  selected_sentences : Int
  estimated_tokens : Int
} derive(Eq, Debug)

///|
fn chars_owned(chars : Array[Char]) -> String {
  StringView::from_iter(chars.iter()).to_owned()
}

///|
fn is_sentence_end(char : Char) -> Bool {
  char == '.' ||
  char == '!' ||
  char == '?' ||
  char == '。' ||
  char == '!' ||
  char == '?' ||
  char == '\n'
}

///|
/// Split prose into non-empty sentences while retaining terminal punctuation.
pub fn split_sentences(text : String) -> Array[String] {
  let sentences : Array[String] = []
  let current : Array[Char] = []
  for char in text.iter() {
    current.push(char)
    if is_sentence_end(char) {
      let sentence = chars_owned(current).trim().to_owned()
      if !sentence.is_empty() {
        sentences.push(sentence)
      }
      current.clear()
    }
  }
  let tail = chars_owned(current).trim().to_owned()
  if !tail.is_empty() {
    sentences.push(tail)
  }
  sentences
}

///|
pub fn extract_keywords(
  text : String,
  limit? : Int = 10,
  tokenizer? : Tokenizer = Tokenizer::new(stopwords=default_stopwords()),
) -> Array[Keyword] {
  if limit <= 0 {
    return []
  }
  let tokens = tokenizer.tokenize(text)
  if tokens.is_empty() {
    return []
  }
  let counts : Map[String, Int] = Map([])
  for token in tokens {
    counts[token] = counts.get_or_default(token, 0) + 1
  }
  let keywords : Array[Keyword] = []
  for pair in counts.iter() {
    let (term, count) = pair
    keywords.push({
      term,
      count,
      frequency: count.to_double() / tokens.length().to_double(),
    })
  }
  keywords.sort_by(fn(a, b) {
    let count_order = b.count.compare(a.count)
    if count_order != 0 {
      count_order
    } else {
      a.term.compare(b.term)
    }
  })
  let out : Array[Keyword] = []
  for i = 0; i < keywords.length() && i < limit; i = i + 1 {
    out.push(keywords[i])
  }
  out
}

///|
fn summary_unique_tokens(text : String, tokenizer : Tokenizer) -> Array[String] {
  let out : Array[String] = []
  for token in tokenizer.tokenize(text) {
    if !out.contains(token) {
      out.push(token)
    }
  }
  out
}

///|
fn sentence_query_score(
  sentence : String,
  query_tokens : Array[String],
  tokenizer : Tokenizer,
) -> Int {
  let sentence_tokens = summary_unique_tokens(sentence, tokenizer)
  let mut score = 0
  for token in query_tokens {
    if sentence_tokens.contains(token) {
      score = score + 1
    }
  }
  score
}

///|
/// Select the most query-relevant sentences, then restore document order.
pub fn summarize_text(
  text : String,
  query? : String = "",
  max_sentences? : Int = 3,
  tokenizer? : Tokenizer = Tokenizer::new(),
) -> TextSummary {
  let sentences = split_sentences(text)
  if sentences.is_empty() || max_sentences <= 0 {
    return {
      text: "",
      original_sentences: sentences.length(),
      selected_sentences: 0,
      estimated_tokens: 0,
    }
  }
  let query_tokens = summary_unique_tokens(query, tokenizer)
  let ranked : Array[(Int, Int)] = []
  for i = 0; i < sentences.length(); i = i + 1 {
    ranked.push(
      (i, sentence_query_score(sentences[i], query_tokens, tokenizer)),
    )
  }
  ranked.sort_by(fn(a, b) {
    let score_order = b.1.compare(a.1)
    if score_order != 0 {
      score_order
    } else {
      a.0.compare(b.0)
    }
  })
  let selected : Array[Int] = []
  for i = 0; i < ranked.length() && i < max_sentences; i = i + 1 {
    selected.push(ranked[i].0)
  }
  selected.sort()
  let builder = StringBuilder()
  for i = 0; i < selected.length(); i = i + 1 {
    if i > 0 {
      builder.write_string(" ")
    }
    builder.write_string(sentences[selected[i]])
  }
  let summary = builder.to_string()
  {
    text: summary,
    original_sentences: sentences.length(),
    selected_sentences: selected.length(),
    estimated_tokens: tokenizer.estimate_tokens(summary),
  }
}

///|
fn first_term_position(text : String, terms : Array[String]) -> Int? {
  let mut best : Int? = None
  for term in terms {
    match text.find(term) {
      Some(position) =>
        match best {
          Some(current) => if position < current { best = Some(position) }
          None => best = Some(position)
        }
      None => ()
    }
  }
  best
}

///|
fn ascii_lower_text(text : String) -> String {
  let chars : Array[Char] = []
  for char in text.iter() {
    let value = char.to_int()
    if value >= 65 && value <= 90 {
      chars.push((value + 32).unsafe_to_char())
    } else {
      chars.push(char)
    }
  }
  chars_owned(chars)
}

///|
/// Create a bounded result snippet centered near the first query match.
pub fn result_snippet(
  text : String,
  query : String,
  max_chars? : Int = 160,
  tokenizer? : Tokenizer = Tokenizer::new(),
) -> String {
  if max_chars <= 0 || text.is_empty() {
    return ""
  }
  if text.length() <= max_chars {
    return text
  }
  let terms = summary_unique_tokens(query, tokenizer)
  let position = first_term_position(ascii_lower_text(text), terms).unwrap_or(0)
  let half = max_chars / 2
  let mut start = if position > half { position - half } else { 0 }
  if start + max_chars > text.length() {
    start = text.length() - max_chars
  }
  let end = start + max_chars
  let builder = StringBuilder()
  if start > 0 {
    builder.write_string("...")
  }
  builder.write_string(text.exact_view(start~, end~).to_owned())
  if end < text.length() {
    builder.write_string("...")
  }
  builder.to_string()
}

///|
pub fn snippets_for_results(
  results : Array[SearchResult],
  query : String,
  max_chars? : Int = 160,
  tokenizer? : Tokenizer = Tokenizer::new(),
) -> Array[SearchResult] {
  let out : Array[SearchResult] = []
  for result in results {
    out.push({
      doc_id: result.doc_id,
      score: result.score,
      text: result_snippet(result.text, query, max_chars~, tokenizer~),
    })
  }
  out
}