///|
/// Split text into owned lines and drop a trailing CR on Windows files.
fn split_lines(text : String) -> Array[String] {
  let lines : Array[String] = []
  if text.is_empty() {
    return lines
  }
  for view in text.split("\n") {
    lines.push(view.to_owned().trim_end(chars="\r").to_owned())
  }
  lines
}

///|
/// Clip a line for stable reports.
fn clip(input : String, max : Int) -> String {
  let trimmed = input.trim()
  if max <= 0 || trimmed.char_length() <= max {
    return trimmed.to_owned()
  }
  let buf = StringBuilder()
  let mut count = 0
  for ch in trimmed {
    if count < max {
      buf.write_char(ch)
      count += 1
    }
  }
  buf.write_string("...")
  buf.to_string()
}

///|
/// Lowercase helper for ASCII-oriented checklist matching.
fn fold_text(input : String) -> String {
  input.to_lower()
}

///|
/// Count non-overlapping occurrences of a term.
fn count_occurrences(haystack : String, needle : String) -> Int {
  if needle.is_empty() {
    return 0
  }
  let hay = fold_text(haystack)
  let low = fold_text(needle)
  hay.split(low).to_array().length() - 1
}

///|
/// True when text contains a folded term.
fn contains_folded(haystack : String, needle : String) -> Bool {
  fold_text(haystack).contains(fold_text(needle))
}

///|
/// Build concise evidence for any of the supplied terms.
fn collect_evidence(
  lines : Array[String],
  terms : Array[String],
  limit : Int,
) -> Array[Evidence] {
  let evidence : Array[Evidence] = []
  for i in 0..= limit {
      break
    }
    let lower = fold_text(lines[i])
    for term in terms {
      if evidence.length() < limit && lower.contains(fold_text(term)) {
        evidence.push({
          line: i + 1,
          snippet: clip(lines[i], 96),
          matched: term,
        })
      }
    }
  }
  evidence
}

///|
/// Evidence for lines over the configured length budget.
fn collect_long_lines(lines : Array[String], max : Int) -> Array[Evidence] {
  let evidence : Array[Evidence] = []
  for i in 0.. max && evidence.length() < 6 {
      evidence.push({
        line: i + 1,
        snippet: clip(lines[i], 96),
        matched: "length=\{len}",
      })
    }
  }
  evidence
}

///|
/// Find the first line index at or after `start` that contains `label`.
fn find_line_after(lines : Array[String], label : String, start : Int) -> Int? {
  let target = fold_text(label)
  let begin = if start < 0 { 0 } else { start }
  for i in begin.. Array[LineToken] {
  let tokens : Array[LineToken] = []
  let lines = split_lines(text.to_owned())
  for i in 0.. DocumentShape {
  let tokens = tokenize_lines(text)
  let mut headings = 0
  let mut bullets = 0
  let mut code_fences = 0
  let mut paragraphs = 0
  let mut blanks = 0
  for token in tokens {
    match token.kind {
      Heading => headings += 1
      Bullet => bullets += 1
      CodeFence => code_fences += 1
      Paragraph => paragraphs += 1
      Blank => blanks += 1
    }
  }
  { lines: tokens.length(), headings, bullets, code_fences, paragraphs, blanks }
}