///|
/// Aggregate analysis of one scanned document.
pub(all) struct AnalysisReport {
  text_length : Int
  match_count : Int
  distinct_label_count : Int
  distinct_keyword_count : Int
  total_weight : Int
  covered_characters : Int
  unmatched_characters : Int
  coverage_percent : Int
  overlap_count : Int
  longest_span : Int
  matches : Array[LexiconMatch]
} derive(Debug, Eq, ToJson)

///|
/// Summarize all matches emitted for a document.
pub fn analyze_text(automaton : Automaton, text : String) -> AnalysisReport {
  analyze_matches(text.length(), automaton.find_all(text))
}

///|
/// Summarize an existing match array when scanning was performed elsewhere.
pub fn analyze_matches(
  text_length : Int,
  matches : ArrayView[LexiconMatch],
) -> AnalysisReport {
  let normalized_length = if text_length < 0 { 0 } else { text_length }
  let copy = matches.to_owned()
  let labels : Map[String, Bool] = Map([])
  let keywords : Map[String, Bool] = Map([])
  let mut total_weight = 0
  let mut longest = 0
  for hit in copy {
    labels[hit.label] = true
    keywords[hit.keyword] = true
    total_weight += hit.weight
    if match_span_length(hit) > longest {
      longest = match_span_length(hit)
    }
  }
  let covered = covered_characters(normalized_length, copy)
  {
    text_length: normalized_length,
    match_count: copy.length(),
    distinct_label_count: labels.length(),
    distinct_keyword_count: keywords.length(),
    total_weight,
    covered_characters: covered,
    unmatched_characters: if normalized_length > covered {
      normalized_length - covered
    } else {
      0
    },
    coverage_percent: percentage(covered, normalized_length),
    overlap_count: overlap_count(copy),
    longest_span: longest,
    matches: copy,
  }
}

///|
/// Count covered source characters using a union of match intervals.
pub fn covered_characters(
  text_length : Int,
  matches : ArrayView[LexiconMatch],
) -> Int {
  let limit = if text_length < 0 { 0 } else { text_length }
  let sorted = sort_matches_by_position(matches)
  let mut covered = 0
  let mut cursor = 0
  for hit in sorted {
    let start = clamp_analysis_int(hit.start, 0, limit)
    let end = clamp_analysis_int(hit.end, start, limit)
    if end > cursor {
      if start > cursor {
        covered += end - start
      } else {
        covered += end - cursor
      }
      cursor = end
    }
  }
  covered
}

///|
/// Convert a numerator into a rounded percentage in the range 0..100.
pub fn percentage(numerator : Int, denominator : Int) -> Int {
  if denominator <= 0 || numerator <= 0 {
    0
  } else if numerator >= denominator {
    100
  } else {
    numerator * 100 / denominator
  }
}

///|
/// Count pairwise overlapping match spans.
pub fn overlap_count(matches : ArrayView[LexiconMatch]) -> Int {
  let mut count = 0
  for left_index in 0.. Int {
  if text_length <= 0 {
    0
  } else {
    matches.length() * 100 / text_length
  }
}

///|
/// A label-level aggregate for dashboards and audit reports.
pub(all) struct LabelSummary {
  label : String
  match_count : Int
  total_weight : Int
  maximum_weight : Int
  covered_characters : Int
  average_span : Int
} derive(Debug, Eq, ToJson)

///|
/// Group matches into deterministic label summaries.
pub fn label_summaries(
  matches : ArrayView[LexiconMatch],
) -> Array[LabelSummary] {
  let grouped : Map[String, Array[LexiconMatch]] = Map([])
  for hit in matches {
    match grouped.get(hit.label) {
      Some(bucket) => bucket.push(hit)
      None => grouped[hit.label] = [hit]
    }
  }
  let output : Array[LabelSummary] = []
  for label, bucket in grouped {
    let mut total_weight = 0
    let mut maximum_weight = 0
    let mut total_span = 0
    for hit in bucket {
      total_weight += hit.weight
      if hit.weight > maximum_weight || total_span == 0 {
        maximum_weight = hit.weight
      }
      total_span += match_span_length(hit)
    }
    output.push({
      label,
      match_count: bucket.length(),
      total_weight,
      maximum_weight,
      covered_characters: covered_characters(1000000000, bucket),
      average_span: if bucket.length() == 0 {
        0
      } else {
        total_span / bucket.length()
      },
    })
  }
  output.sort_by(fn(left, right) {
    if left.match_count != right.match_count {
      right.match_count - left.match_count
    } else {
      left.label.compare(right.label)
    }
  })
  output
}

///|
/// A keyword-level aggregate for identifying noisy or high-value rules.
pub(all) struct KeywordSummary {
  keyword : String
  label : String
  match_count : Int
  total_weight : Int
  first_position : Int
  last_position : Int
} derive(Debug, Eq, ToJson)

///|
/// Summarize repeated keyword occurrences.
pub fn keyword_summaries(
  matches : ArrayView[LexiconMatch],
) -> Array[KeywordSummary] {
  let grouped : Map[String, KeywordSummary] = Map([])
  for hit in matches {
    let key = entry_key({
      keyword: hit.keyword,
      label: hit.label,
      weight: hit.weight,
    })
    match grouped.get(key) {
      Some(old) =>
        grouped[key] = {
          keyword: old.keyword,
          label: old.label,
          match_count: old.match_count + 1,
          total_weight: old.total_weight + hit.weight,
          first_position: if hit.start < old.first_position {
            hit.start
          } else {
            old.first_position
          },
          last_position: if hit.end > old.last_position {
            hit.end
          } else {
            old.last_position
          },
        }
      None =>
        grouped[key] = {
          keyword: hit.keyword,
          label: hit.label,
          match_count: 1,
          total_weight: hit.weight,
          first_position: hit.start,
          last_position: hit.end,
        }
    }
  }
  let output : Array[KeywordSummary] = []
  for _, summary in grouped {
    output.push(summary)
  }
  output.sort_by(fn(left, right) {
    if left.match_count != right.match_count {
      right.match_count - left.match_count
    } else {
      left.keyword.compare(right.keyword)
    }
  })
  output
}

///|
/// Return the match with the largest span, if any.
pub fn longest_match(matches : ArrayView[LexiconMatch]) -> LexiconMatch? {
  let mut best : LexiconMatch? = None
  for hit in matches {
    match best {
      None => best = Some(hit)
      Some(old) =>
        if match_span_length(hit) > match_span_length(old) ||
          (
            match_span_length(hit) == match_span_length(old) &&
            hit.weight > old.weight
          ) {
          best = Some(hit)
        }
    }
  }
  best
}

///|
/// Return the most frequently emitted keyword, if any.
pub fn most_frequent_keyword(matches : ArrayView[LexiconMatch]) -> String? {
  let summaries = keyword_summaries(matches)
  match summaries.get(0) {
    Some(summary) => Some(summary.keyword)
    None => None
  }
}

///|
/// Return source intervals that contain at least one match.
pub fn coverage_ranges(
  text_length : Int,
  matches : ArrayView[LexiconMatch],
) -> Array[(Int, Int)] {
  let limit = if text_length < 0 { 0 } else { text_length }
  let sorted = sort_matches_by_position(matches)
  let ranges : Array[(Int, Int)] = []
  for hit in sorted {
    let start = clamp_analysis_int(hit.start, 0, limit)
    let end = clamp_analysis_int(hit.end, start, limit)
    if end > start {
      match ranges.get(ranges.length() - 1) {
        Some((old_start, old_end)) if start <= old_end =>
          ranges[ranges.length() - 1] = (
            old_start,
            if end > old_end {
              end
            } else {
              old_end
            },
          )
        _ => ranges.push((start, end))
      }
    }
  }
  ranges
}

///|
/// Return gaps between covered intervals.
pub fn uncovered_ranges(
  text_length : Int,
  matches : ArrayView[LexiconMatch],
) -> Array[(Int, Int)] {
  let output : Array[(Int, Int)] = []
  let limit = if text_length < 0 { 0 } else { text_length }
  let cursor_ranges = coverage_ranges(limit, matches)
  let mut cursor = 0
  for range in cursor_ranges {
    let start = range.0
    let end = range.1
    if start > cursor {
      output.push((cursor, start))
    }
    cursor = end
  }
  if cursor < limit {
    output.push((cursor, limit))
  }
  output
}

///|
/// Render label summaries as a stable TSV report.
pub fn label_report_tsv(matches : ArrayView[LexiconMatch]) -> String {
  let output = StringBuilder()
  output.write_string(
    "label\tmatches\ttotal_weight\tmaximum_weight\taverage_span\n",
  )
  for summary in label_summaries(matches) {
    output.write_string(
      "\{summary.label}\t\{summary.match_count}\t\{summary.total_weight}\t\{summary.maximum_weight}\t\{summary.average_span}\n",
    )
  }
  output.to_string()
}

///|
/// Return a histogram of match span lengths.
pub fn span_histogram(matches : ArrayView[LexiconMatch]) -> Map[Int, Int] {
  let histogram : Map[Int, Int] = Map([])
  for hit in matches {
    let span = match_span_length(hit)
    histogram[span] = histogram.get_or_default(span, 0) + 1
  }
  histogram
}

///|
/// Calculate the arithmetic mean span length.
pub fn average_span(matches : ArrayView[LexiconMatch]) -> Int {
  if matches.length() == 0 {
    0
  } else {
    let mut total = 0
    for hit in matches {
      total += match_span_length(hit)
    }
    total / matches.length()
  }
}

///|
/// Return the median span length, using lower-middle selection for even input.
pub fn median_span(matches : ArrayView[LexiconMatch]) -> Int {
  if matches.length() == 0 {
    0
  } else {
    let spans = [ for hit in matches => match_span_length(hit) ]
    spans.sort_by((left, right) => left - right)
    spans[(spans.length() - 1) / 2]
  }
}

///|
/// Return the count of distinct labels in a match array.
pub fn distinct_label_count(matches : ArrayView[LexiconMatch]) -> Int {
  let labels : Map[String, Bool] = Map([])
  for hit in matches {
    labels[hit.label] = true
  }
  labels.length()
}

///|
fn clamp_analysis_int(value : Int, minimum : Int, maximum : Int) -> Int {
  if value < minimum {
    minimum
  } else if value > maximum {
    maximum
  } else {
    value
  }
}