///|
/// Matching policy used while reconstructing highlights from analyzed text.
pub(all) enum HighlightMode {
  Terms
  Phrase
} derive(Eq, @debug.Debug)

///|
/// One match in source-text UTF-8 byte offsets.
pub(all) struct HighlightSpan {
  start_offset : Int
  end_offset : Int
} derive(Eq, @debug.Debug)

///|
/// One UTF-8-safe source fragment. Match offsets remain relative to the full
/// source value so callers can map them back without losing provenance.
pub(all) struct HighlightFragment {
  text : String
  start_offset : Int
  end_offset : Int
  matches : ReadOnlyArray[HighlightSpan]
  prefix_omitted : Bool
  suffix_omitted : Bool
} derive(Eq, @debug.Debug)

///|
/// Configuration for one highlight operation.
pub struct HighlightOptions {
  mode : HighlightMode
  max_chars : Int
}

///|
/// Creates term-oriented highlighting with a 160-Unicode-scalar fragment.
pub fn HighlightOptions::new() -> HighlightOptions {
  { mode: Terms, max_chars: 160 }
}

///|
pub fn HighlightOptions::with_mode(
  self : HighlightOptions,
  mode : HighlightMode,
) -> HighlightOptions {
  { mode, max_chars: self.max_chars }
}

///|
pub fn HighlightOptions::with_max_chars(
  self : HighlightOptions,
  max_chars : Int,
) -> HighlightOptions {
  guard max_chars > 0 else { abort("highlight max_chars must be positive") }
  { mode: self.mode, max_chars }
}

///|
/// Analyzer-driven highlighter. Like QueryParser, it snapshots the tokenizer
/// registry so subsequent application mutations cannot change its behavior.
pub struct Highlighter {
  parser : QueryParser
}

///|
pub fn Highlighter::new(
  schema : Schema,
  tokenizers : TokenizerManager,
) -> Highlighter {
  { parser: QueryParser::new(schema, tokenizers) }
}

///|
fn highlight_utf8_width(ch : Char) -> Int {
  let code_point = ch.to_int()
  if code_point <= 0x7F {
    1
  } else if code_point <= 0x7FF {
    2
  } else if code_point <= 0xFFFF {
    3
  } else {
    4
  }
}

///|
fn highlight_boundaries(text : String) -> Array[Int] {
  let boundaries : Array[Int] = [0]
  let mut byte_offset = 0
  for ch in text {
    byte_offset += highlight_utf8_width(ch)
    boundaries.push(byte_offset)
  }
  boundaries
}

///|
fn highlight_boundary_index(boundaries : Array[Int], byte_offset : Int) -> Int {
  match boundaries.search_by(boundary => boundary == byte_offset) {
    Some(index) => index
    None => abort("highlight offset is not on a UTF-8 boundary")
  }
}

///|
fn validate_highlight_tokens(
  text : String,
  tokens : Array[Token],
) -> Unit raise AnalysisError {
  ignore(@analysis.TokenGraph::from_tokens(tokens))
  let boundaries = highlight_boundaries(text)
  let byte_length = boundaries[boundaries.length() - 1]
  for token in tokens {
    guard token.end_offset <= byte_length else {
      raise AnalysisError::InvalidTokenGraph(
        "token offsets exceed the analyzed UTF-8 source",
      )
    }
    guard boundaries.search_by(offset => offset == token.start_offset)
      is Some(_) &&
      boundaries.search_by(offset => offset == token.end_offset) is Some(_) else {
      raise AnalysisError::InvalidTokenGraph(
        "token offsets must be UTF-8 boundaries",
      )
    }
  }
}

///|
fn highlight_slice(
  text : String,
  start_offset : Int,
  end_offset : Int,
) -> String {
  let bytes = @utf8.encode(text)
  guard start_offset >= 0 &&
    end_offset >= start_offset &&
    end_offset <= bytes.length() else {
    abort("invalid highlight UTF-8 range")
  }
  @utf8.decode(bytes.view(start=start_offset, end=end_offset)) catch {
    _ => abort("highlight range split a UTF-8 sequence")
  }
}

///|
fn compare_highlight_spans(left : HighlightSpan, right : HighlightSpan) -> Int {
  if left.start_offset != right.start_offset {
    left.start_offset.compare(right.start_offset)
  } else {
    left.end_offset.compare(right.end_offset)
  }
}

///|
fn merged_highlight_spans(
  raw_spans : Array[HighlightSpan],
) -> Array[HighlightSpan] {
  raw_spans.sort_by(compare_highlight_spans)
  let merged : Array[HighlightSpan] = []
  for span in raw_spans {
    guard span.start_offset >= 0 && span.end_offset >= span.start_offset else {
      abort("invalid analyzed token offsets")
    }
    if merged.length() == 0 {
      merged.push(span)
      continue
    }
    let last_index = merged.length() - 1
    let last = merged[last_index]
    if span.start_offset <= last.end_offset {
      merged[last_index] = {
        start_offset: last.start_offset,
        end_offset: if span.end_offset > last.end_offset {
          span.end_offset
        } else {
          last.end_offset
        },
      }
    } else {
      merged.push(span)
    }
  }
  merged
}

///|
fn term_highlight_spans(
  query_tokens : Array[Token],
  source_tokens : Array[Token],
) -> Array[HighlightSpan] {
  let terms : Array[String] = []
  for token in query_tokens {
    if terms.search_by(text => text == token.text) is None {
      terms.push(token.text)
    }
  }
  let spans : Array[HighlightSpan] = []
  for token in source_tokens {
    if terms.search_by(text => text == token.text) is Some(_) {
      spans.push({
        start_offset: token.start_offset,
        end_offset: token.end_offset,
      })
    }
  }
  merged_highlight_spans(spans)
}

///|
fn source_token_at(
  tokens : ReadOnlyArray[Token],
  text : String,
  position : Int,
) -> Token? {
  match
    tokens.search_by(token => token.position == position && token.text == text) {
    Some(index) => Some(tokens[index])
    None => None
  }
}

///|
fn phrase_highlight_spans(
  query_tokens : Array[Token],
  source_tokens : Array[Token],
) -> Array[HighlightSpan] raise AnalysisError {
  let paths = @analysis.TokenGraph::from_tokens(query_tokens).finite_strings()
  let source_paths = @analysis.TokenGraph::from_tokens(source_tokens).finite_strings()
  let spans : Array[HighlightSpan] = []
  for path in paths {
    let path_tokens = path.tokens()
    if path_tokens.length() == 0 {
      continue
    }
    let query_base = path_tokens[0].position
    for source_path in source_paths {
      let source_path_tokens = source_path.tokens()
      for source_start in source_path_tokens {
        if source_start.text != path_tokens[0].text {
          continue
        }
        let mut start_offset = source_start.start_offset
        let mut end_offset = source_start.end_offset
        let mut matched = true
        for query_index in 1.. {
              if source_token.start_offset < start_offset {
                start_offset = source_token.start_offset
              }
              if source_token.end_offset > end_offset {
                end_offset = source_token.end_offset
              }
            }
            None => {
              matched = false
              break
            }
          }
        }
        if matched {
          spans.push({ start_offset, end_offset })
        }
      }
    }
  }
  merged_highlight_spans(spans)
}

///|
fn highlight_window_for_span(
  span : HighlightSpan,
  boundaries : Array[Int],
  max_chars : Int,
) -> (Int, Int) {
  let total_chars = boundaries.length() - 1
  let span_start = highlight_boundary_index(boundaries, span.start_offset)
  let span_end = highlight_boundary_index(boundaries, span.end_offset)
  let span_chars = span_end - span_start
  if span_chars >= max_chars {
    return (span_start, span_end)
  }
  let mut start = span_start - (max_chars - span_chars) / 2
  if start < 0 {
    start = 0
  }
  let mut end = start + max_chars
  if end < span_end {
    end = span_end
    start = end - max_chars
  }
  if end > total_chars {
    end = total_chars
    start = end - max_chars
    if start < 0 {
      start = 0
    }
  }
  (start, end)
}

///|
fn build_highlight_fragment(
  source_text : String,
  spans : Array[HighlightSpan],
  max_chars : Int,
) -> HighlightFragment? {
  if spans.length() == 0 {
    return None
  }
  let boundaries = highlight_boundaries(source_text)
  let total_chars = boundaries.length() - 1
  let mut best_start_char = 0
  let mut best_end_char = total_chars
  if total_chars > max_chars {
    let mut best_count = -1
    for anchor in spans {
      let (candidate_start, candidate_end) = highlight_window_for_span(
        anchor, boundaries, max_chars,
      )
      let candidate_start_offset = boundaries[candidate_start]
      let candidate_end_offset = boundaries[candidate_end]
      let mut count = 0
      for span in spans {
        if span.start_offset >= candidate_start_offset &&
          span.end_offset <= candidate_end_offset {
          count += 1
        }
      }
      if count > best_count ||
        (count == best_count && candidate_start < best_start_char) {
        best_count = count
        best_start_char = candidate_start
        best_end_char = candidate_end
      }
    }
  }
  let start_offset = boundaries[best_start_char]
  let end_offset = boundaries[best_end_char]
  let selected : Array[HighlightSpan] = []
  for span in spans {
    if span.start_offset >= start_offset && span.end_offset <= end_offset {
      selected.push(span)
    }
  }
  Some({
    text: highlight_slice(source_text, start_offset, end_offset),
    start_offset,
    end_offset,
    matches: ReadOnlyArray::from_array(selected),
    prefix_omitted: start_offset > 0,
    suffix_omitted: end_offset < @utf8.encode(source_text).length(),
  })
}

///|
/// Re-analyzes query and source text using the field's configured tokenizer,
/// then returns one deterministic best fragment when at least one match exists.
pub fn Highlighter::highlight(
  self : Highlighter,
  field_id : FieldId,
  query_text : String,
  source_text : String,
  options : HighlightOptions,
) -> HighlightFragment? raise AnalysisError {
  let query_tokens = self.parser.analyze(field_id, query_text)
  guard query_tokens.length() > 0 else { raise AnalysisError::EmptyQuery }
  let source_tokens = self.parser.analyze(field_id, source_text)
  validate_highlight_tokens(query_text, query_tokens)
  validate_highlight_tokens(source_text, source_tokens)
  let spans = match options.mode {
    Terms => term_highlight_spans(query_tokens, source_tokens)
    Phrase => phrase_highlight_spans(query_tokens, source_tokens)
  }
  build_highlight_fragment(source_text, spans, options.max_chars)
}

///|
/// Builds a fragment directly from persisted posting offsets, avoiding source
/// re-analysis. `value_index` selects one value of a multi-valued text field.
pub fn Highlighter::highlight_postings(
  _self : Highlighter,
  segment : Segment,
  doc_id : DocId,
  field_id : FieldId,
  terms : ReadOnlyArray[String],
  source_text : String,
  value_index? : Int = 0,
  options? : HighlightOptions = HighlightOptions::new(),
) -> HighlightFragment? {
  let spans : Array[HighlightSpan] = []
  for text in terms {
    let postings = segment.postings_for(Term::new(field_id, text))
    match postings.search_by(posting => posting.doc_id == doc_id) {
      Some(posting_index) =>
        for occurrence in postings[posting_index].occurrences() {
          if occurrence.value_index == value_index {
            spans.push({
              start_offset: occurrence.start_offset,
              end_offset: occurrence.end_offset,
            })
          }
        }
      None => ()
    }
  }
  let merged = merged_highlight_spans(spans)
  // Persisted offsets are checked against the original stored value before
  // fragment reconstruction, catching analyzer/offset incompatibilities.
  let boundaries = highlight_boundaries(source_text)
  for span in merged {
    ignore(highlight_boundary_index(boundaries, span.start_offset))
    ignore(highlight_boundary_index(boundaries, span.end_offset))
  }
  build_highlight_fragment(source_text, merged, options.max_chars)
}

///|
fn add_unique_highlight_term(terms : Array[String], text : String) -> Unit {
  if terms.search_by(existing => existing == text) is None {
    terms.push(text)
  }
}

///|
fn QueryStringParser::collect_highlight_terms(
  self : QueryStringParser,
  ast : UserQueryAst,
  node_index : Int,
  target_field : FieldId,
  inherited_field : FieldId?,
  segment : Segment,
  terms : Array[String],
) -> Unit {
  let node = ast.nodes[node_index]
  match node.kind {
    Literal(text, mode) => {
      if inherited_field is Some(field_id) && field_id != target_field {
        return
      }
      match mode {
        RegexMode => {
          let regex = @string.Regex("^(?:" + text + ")$") catch { _ => return }
          for term in segment.terms() {
            if term.field_id == target_field &&
              regex.execute(term.text) is Some(_) {
              add_unique_highlight_term(terms, term.text)
              if terms.length() >= 256 {
                break
              }
            }
          }
        }
        Plain => {
          let (literal, has_wildcard, _) = query_pattern_details(text)
          if has_wildcard {
            for term in segment.terms() {
              if term.field_id == target_field &&
                wildcard_matches(text, term.text) {
                add_unique_highlight_term(terms, term.text)
                if terms.length() >= 256 {
                  break
                }
              }
            }
          } else {
            let parser = QueryParser::new(self.schema, self.tokenizers)
            let tokens = parser.analyze(target_field, literal) catch { _ => [] }
            for token in tokens {
              add_unique_highlight_term(terms, token.text)
            }
          }
        }
        PhraseMode => {
          let parser = QueryParser::new(self.schema, self.tokenizers)
          let tokens = parser.analyze(target_field, text) catch { _ => [] }
          for token in tokens {
            add_unique_highlight_term(terms, token.text)
          }
        }
      }
    }
    Range(_, _, _, _) => ()
    FieldScope(name, child, _, _) =>
      match self.schema.field(name) {
        Some(field_id) =>
          self.collect_highlight_terms(
            ast,
            child,
            target_field,
            Some(field_id),
            segment,
            terms,
          )
        None => ()
      }
    Conjunction(children) | Disjunction(children) =>
      for child in children {
        self.collect_highlight_terms(
          ast, child, target_field, inherited_field, segment, terms,
        )
      }
    Required(child) | Boost(child, _) | Group(child) | Proximity(child, _) =>
      self.collect_highlight_terms(
        ast, child, target_field, inherited_field, segment, terms,
      )
    Prohibited(_) => ()
  }
}

///|
/// Query-string-aware persisted-offset highlighting. The AST controls field
/// scope and excludes prohibited clauses; wildcard/regex literals are expanded
/// against the exact segment dictionary before offsets are read.
pub fn QueryStringParser::highlight_query(
  self : QueryStringParser,
  highlighter : Highlighter,
  segment : Segment,
  doc_id : DocId,
  field_id : FieldId,
  query_text : String,
  source_text : String,
  value_index? : Int = 0,
  options? : HighlightOptions = HighlightOptions::new(),
) -> HighlightFragment? raise QueryStringError {
  let ast = self.parse_ast(query_text)
  let terms : Array[String] = []
  self.collect_highlight_terms(ast, ast.root, field_id, None, segment, terms)
  highlighter.highlight_postings(
    segment,
    doc_id,
    field_id,
    ReadOnlyArray::from_array(terms),
    source_text,
    value_index~,
    options~,
  )
}

///|
/// Inserts caller-provided markers around matches without applying an HTML or
/// terminal escaping policy. Callers remain responsible for output escaping.
pub fn HighlightFragment::render(
  self : HighlightFragment,
  before : String,
  after : String,
) -> String {
  let mut output = ""
  let mut cursor = 0
  for span in self.matches {
    let start = span.start_offset - self.start_offset
    let end = span.end_offset - self.start_offset
    output += highlight_slice(self.text, cursor, start)
    output += before
    output += highlight_slice(self.text, start, end)
    output += after
    cursor = end
  }
  output += highlight_slice(self.text, cursor, @utf8.encode(self.text).length())
  output
}