///|
/// Markdown find/replace domain, extracted from MoMark's app layer so any
/// MoUI Markdown surface (MoUI Studio `.md` panels, embedders) shares the
/// same matching semantics.
///
/// All ranges are character indices (not UTF-16 offsets), matching
/// `MarkdownEditorSelection`.

///|
/// Find/replace options. Defaults are case-insensitive substring matching.
///
/// `pub(all)`: consumers outside this package must be able to build
/// non-default option sets (the find-bar toggles write these fields).
pub(all) struct MarkdownFindOptions {
  case_sensitive : Bool
  whole_word : Bool
  regex : Bool
} derive(Eq, Debug, ToJson)

///|
pub extend MarkdownFindOptions with Eq::{equal, not_equal}

///|
pub extend MarkdownFindOptions with Debug::{to_repr}

///|
pub extend MarkdownFindOptions with ToJson::{to_json}

///|
pub fn MarkdownFindOptions::default() -> MarkdownFindOptions {
  { case_sensitive: false, whole_word: false, regex: false, }
}

///|
/// A find query seeded from the caret word: `query` is the text, `range`
/// the selection to activate once the find bar opens.
pub(all) struct MarkdownFindSeed {
  query : String
  range : @core.TextRange
} derive(Eq, Debug, ToJson)

///|
pub extend MarkdownFindSeed with Debug::{to_repr}

///|
pub extend MarkdownFindSeed with Eq::{equal, not_equal}

///|
pub extend MarkdownFindSeed with ToJson::{to_json}

///|
/// All matches of `query` in `source`, in document order.
pub fn markdown_find_matches(
  source : String,
  query : String,
  options? : MarkdownFindOptions = MarkdownFindOptions::default(),
) -> Array[@core.TextRange] {
  let matches : Array[@core.TextRange] = []
  if query == "" {
    return matches
  }
  if options.regex {
    return markdown_find_regex_matches(source, query, options)
  }
  let query_chars = if options.case_sensitive {
    query.to_array()
  } else {
    query.to_lower().to_array()
  }
  let query_length = query_chars.length()
  if query_length == 0 {
    return matches
  }
  let source_utf16_length = source.length()
  let mut offset = 0
  let mut index = 0
  let mut previous_char : Char? = None
  while offset < source_utf16_length {
    match
      markdown_find_query_match_at_utf16(
        source,
        source_utf16_length,
        offset,
        query_chars,
        case_sensitive=options.case_sensitive,
      ) {
      Some((end_offset, last_char)) => {
        let next_char = if end_offset < source_utf16_length {
          source.get_char(end_offset)
        } else {
          None
        }
        if !options.whole_word ||
          markdown_find_whole_word_boundary(previous_char, next_char) {
          matches.push(
            @core.TextRange::new(start=index, end=index + query_length),
          )
          offset = end_offset
          index = index + query_length
          previous_char = Some(last_char)
          continue
        }
      }
      None => ()
    }
    match source.get_char(offset) {
      Some(ch) => {
        offset = offset + ch.utf16_len()
        index = index + 1
        previous_char = Some(ch)
      }
      None => break
    }
  }
  matches
}

///|
fn markdown_find_regex_matches(
  source : String,
  query : String,
  options : MarkdownFindOptions,
) -> Array[@core.TextRange] {
  let matches : Array[@core.TextRange] = []
  match markdown_find_compile_regex(query, options.case_sensitive) {
    Some(regex) => {
      let source_length = @richtext.markdown_editor_text_length(source)
      for found in regex.find(source) {
        let start = found.before().char_length()
        let end = start + found.content().char_length()
        // Zero-width matches do not have a useful selection/replacement range
        // in the current editor flow, so leave them out until that interaction
        // has an explicit cursor policy.
        if start == end {
          continue
        }
        if !options.whole_word ||
          markdown_find_regex_whole_word_boundary(
            source, source_length, start, end,
          ) {
          matches.push(@core.TextRange::new(start~, end~))
        }
      }
    }
    None => ()
  }
  matches
}

///|
/// Compile `query` into a regex, wrapping case-insensitively when needed.
/// Returns `None` for invalid patterns.
pub fn markdown_find_compile_regex(
  query : String,
  case_sensitive : Bool,
) -> Regex? {
  let pattern = if case_sensitive { query } else { "(?i:" + query + ")" }
  try Regex(pattern) catch {
    _ => None
  } noraise {
    regex => Some(regex)
  }
}

///|
fn markdown_find_regex_whole_word_boundary(
  source : String,
  source_length : Int,
  start : Int,
  end : Int,
) -> Bool {
  let before = if start <= 0 {
    None
  } else {
    source.get_char(
      markdown_find_utf16_offset_for_char_index(
        source,
        source_length,
        start - 1,
      ),
    )
  }
  let after = if end >= source_length {
    None
  } else {
    source.get_char(
      markdown_find_utf16_offset_for_char_index(source, source_length, end),
    )
  }
  markdown_find_whole_word_boundary(before, after)
}

///|
fn markdown_find_query_match_at_utf16(
  source : String,
  source_utf16_length : Int,
  offset : Int,
  query : Array[Char],
  case_sensitive~ : Bool,
) -> (Int, Char)? {
  let mut cursor = offset
  let mut last_char = query[0]
  for expected in query {
    if cursor >= source_utf16_length {
      return None
    }
    match source.get_char(cursor) {
      Some(ch) => {
        let actual = if case_sensitive { ch } else { ch.to_ascii_lowercase() }
        if actual != expected {
          return None
        }
        last_char = ch
        cursor = cursor + ch.utf16_len()
      }
      None => return None
    }
  }
  Some((cursor, last_char))
}

///|
fn markdown_find_whole_word_boundary(before : Char?, after : Char?) -> Bool {
  let before_ok = match before {
    Some(ch) => !@richtext.markdown_editor_ascii_word_char(ch)
    None => true
  }
  let after_ok = match after {
    Some(ch) => !@richtext.markdown_editor_ascii_word_char(ch)
    None => true
  }
  before_ok && after_ok
}

///|
fn markdown_find_utf16_offset_for_char_index(
  source : String,
  char_length : Int,
  index : Int,
) -> Int {
  let index = @richtext.markdown_editor_clamp_int(index, 0, char_length)
  if index == char_length {
    source.length()
  } else {
    source.offset_of_nth_char(index).unwrap_or(source.length())
  }
}

///|
/// The query to seed when the find bar opens over a non-empty single-line
/// selection.
pub fn markdown_find_query_from_selection(
  source : String,
  selection : @richtext.MarkdownEditorSelection,
) -> String? {
  guard selection.selection is Some(range) else { return None }
  let normalized = range.normalized()
  if normalized.start == normalized.end {
    return None
  }
  let selected = @richtext.markdown_editor_substring(
    source,
    normalized.start,
    normalized.end,
  )
  if selected == "" || selected.contains("\n") {
    None
  } else {
    Some(selected)
  }
}

///|
/// The find seed around a collapsed caret: the ASCII word under or directly
/// before the caret. Returns `None` when the caret is not on a word.
pub fn markdown_find_seed_from_caret_word(
  source : String,
  selection : @richtext.MarkdownEditorSelection,
) -> MarkdownFindSeed? {
  match selection.selection {
    Some(range) => {
      let normalized = range.normalized()
      if normalized.start != normalized.end {
        return None
      }
    }
    None => ()
  }
  let source_length = @richtext.markdown_editor_text_length(source)
  if source_length == 0 {
    return None
  }
  let caret = @richtext.markdown_editor_clamp_int(
    selection.caret,
    0,
    source_length,
  )
  let (anchor, anchor_offset) = if caret < source_length {
    let offset = markdown_find_utf16_offset_for_char_index(
      source, source_length, caret,
    )
    match source.get_char(offset) {
      Some(ch) if @richtext.markdown_editor_ascii_word_char(ch) =>
        (caret, offset)
      _ =>
        if caret > 0 {
          let previous_offset = markdown_find_utf16_offset_for_char_index(
            source,
            source_length,
            caret - 1,
          )
          match source.get_char(previous_offset) {
            Some(ch) if @richtext.markdown_editor_ascii_word_char(ch) =>
              (caret - 1, previous_offset)
            _ => return None
          }
        } else {
          return None
        }
    }
  } else if caret > 0 {
    let previous_offset = markdown_find_utf16_offset_for_char_index(
      source,
      source_length,
      caret - 1,
    )
    match source.get_char(previous_offset) {
      Some(ch) if @richtext.markdown_editor_ascii_word_char(ch) =>
        (caret - 1, previous_offset)
      _ => return None
    }
  } else {
    return None
  }
  let mut start = anchor
  let mut start_offset = anchor_offset
  while start > 0 && start_offset > 0 {
    let previous_offset = start_offset - 1
    match source.get_char(previous_offset) {
      Some(ch) if @richtext.markdown_editor_ascii_word_char(ch) => {
        start = start - 1
        start_offset = previous_offset
      }
      _ => break
    }
  }
  let mut end = anchor + 1
  let mut end_offset = anchor_offset + 1
  let source_utf16_length = source.length()
  while end < source_length && end_offset < source_utf16_length {
    match source.get_char(end_offset) {
      Some(ch) if @richtext.markdown_editor_ascii_word_char(ch) => {
        end = end + 1
        end_offset = end_offset + 1
      }
      _ => break
    }
  }
  if start == end {
    None
  } else {
    Some({
      query: source.exact_view(start=start_offset, end=end_offset).to_owned(),
      range: @core.TextRange::new(start~, end~),
    })
  }
}

///|
/// Index of the next match at or after the selection end, wrapping to the
/// first match.
pub fn markdown_find_next_index(
  matches : Array[@core.TextRange],
  selection : @richtext.MarkdownEditorSelection,
) -> Int {
  if matches.length() == 0 {
    return -1
  }
  let anchor = match selection.selection {
    Some(range) => range.normalized().end
    None => selection.caret
  }
  for index in 0..= anchor {
      return index
    }
  }
  0
}

///|
/// Index of the previous match ending at or before the selection start,
/// wrapping to the last match.
pub fn markdown_find_previous_index(
  matches : Array[@core.TextRange],
  selection : @richtext.MarkdownEditorSelection,
) -> Int {
  if matches.length() == 0 {
    return -1
  }
  let anchor = match selection.selection {
    Some(range) => range.normalized().start
    None => selection.caret
  }
  let mut index = matches.length() - 1
  while index >= 0 {
    if matches[index].end <= anchor {
      return index
    }
    index = index - 1
  }
  matches.length() - 1
}

///|
/// Index of the match exactly equal to the current selection, or -1.
pub fn markdown_find_active_index(
  matches : Array[@core.TextRange],
  selection : @richtext.MarkdownEditorSelection,
) -> Int {
  guard selection.selection is Some(range) else { return -1 }
  let active = range.normalized()
  for index in 0.. Int {
  if matches.length() == 0 {
    return -1
  }
  match selection.selection {
    Some(range) => {
      let active = range.normalized()
      for index in 0.. ()
  }
  markdown_find_next_index(matches, selection)
}

///|
/// Replace one character-index range with `replacement`.
pub fn markdown_replace_source_range(
  source : String,
  range : @core.TextRange,
  replacement : String,
) -> String {
  let normalized = range.normalized()
  @richtext.markdown_editor_substring(source, 0, normalized.start) +
  replacement +
  @richtext.markdown_editor_substring(
    source,
    normalized.end,
    @richtext.markdown_editor_text_length(source),
  )
}

///|
/// The match to activate after replacing `[replaced_start, replacement_end)`:
/// the first match at/after the replacement, else the first one before it.
pub fn markdown_next_match_after_replacement(
  matches : Array[@core.TextRange],
  replaced_start : Int,
  replacement_end : Int,
) -> @core.TextRange? {
  for range in matches {
    let normalized = range.normalized()
    if normalized.start >= replacement_end {
      return Some(normalized)
    }
  }
  for range in matches {
    let normalized = range.normalized()
    if normalized.end <= replaced_start {
      return Some(normalized)
    }
  }
  None
}

///|
/// Replace all matches in a single pass. Matches must be sorted and
/// non-overlapping; out-of-order/ranges past the end are clamped and skipped.
pub fn markdown_replace_all_matches(
  source : String,
  matches : Array[@core.TextRange],
  replacement : String,
) -> String {
  let builder = StringBuilder()
  let source_length = @richtext.markdown_editor_text_length(source)
  let utf16_length = source.length()
  let mut char_index = 0
  let mut utf16_offset = 0
  for range in matches {
    let normalized = range.normalized()
    let start = @richtext.markdown_editor_clamp_int(
      normalized.start,
      char_index,
      source_length,
    )
    let start_offset = markdown_find_advance_source_offset(
      source, utf16_length, char_index, utf16_offset, start,
    )
    builder.write_string(
      source.exact_view(start=utf16_offset, end=start_offset).to_owned(),
    )
    builder.write_string(replacement)
    let end = @richtext.markdown_editor_clamp_int(
      normalized.end,
      start,
      source_length,
    )
    let end_offset = markdown_find_advance_source_offset(
      source, utf16_length, start, start_offset, end,
    )
    char_index = end
    utf16_offset = end_offset
  }
  builder.write_string(
    source.exact_view(start=utf16_offset, end=utf16_length).to_owned(),
  )
  builder.to_string()
}

///|
fn markdown_find_advance_source_offset(
  source : String,
  utf16_length : Int,
  from_index : Int,
  from_offset : Int,
  target_index : Int,
) -> Int {
  let mut index = from_index
  let mut offset = from_offset
  while index < target_index && offset < utf16_length {
    match source.get_char(offset) {
      Some(ch) => {
        offset = offset + ch.utf16_len()
        index = index + 1
      }
      None => return utf16_length
    }
  }
  offset
}

///|
/// The find-bar status label: "Ready", "Invalid regex", "No match", or the
/// active match position ("n/m").
pub fn markdown_find_status_label(
  query : String,
  matches : Array[@core.TextRange],
  selection : @richtext.MarkdownEditorSelection,
  options? : MarkdownFindOptions = MarkdownFindOptions::default(),
) -> String {
  if query == "" {
    "Ready"
  } else if options.regex &&
    markdown_find_compile_regex(query, options.case_sensitive) is None {
    "Invalid regex"
  } else if matches.length() == 0 {
    "No match"
  } else {
    let active = markdown_find_active_index(matches, selection)
    let visible_index = if active >= 0 { active + 1 } else { 0 }
    visible_index.to_string() + "/" + matches.length().to_string()
  }
}