// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// One match: where it sits in the text, and every group's span.
///
/// A span is a code-point range, so `start` and `end` read the way Python's do
/// — characters, not UTF-16 units — and `group` slices the original text back
/// out by those offsets.
pub(all) struct Match {
  priv pattern : Pattern
  priv text : String
  priv result : @exec.MatchResult
  /// The `pos` the search started at (Python's `Match.pos`).
  priv pos : Int32
  /// The `endpos` the search was bounded by (Python's `Match.endpos`).
  priv endpos : Int32
}

///|
/// The `Pattern` that produced this match.
pub fn Match::pattern(self : Match) -> Pattern {
  self.pattern
}

///|
/// The subject text.
pub fn Match::string(self : Match) -> String {
  self.text
}

///|
/// Map a group key to its slot, refusing keys the pattern does not have.
fn Match::slot_of(self : Match, group : Group) -> Int32 raise @parse.Refused {
  match group {
    Whole => 0
    Num(n~) => {
      if n < 0 || n > self.pattern.groups() {
        raise @parse.Pattern(msg="no such group \{n}", at=0, line=1, column=1)
      }
      n
    }
    Name(n=name) =>
      match self.pattern.groupindex().get(name) {
        None =>
          raise @parse.Pattern(
            msg="no such group '\{name}'",
            at=0,
            line=1,
            column=1,
          )
        Some(slots) => {
          let slot : Int32 = slots[0]
          slot
        }
      }
  }
}

///|
/// Code-point offset where the whole match begins.
pub fn[K : GroupKey] Match::start(
  self : Match,
  group : K,
) -> Int32 raise @parse.Refused {
  let slot = self.slot_of(group.to_group())
  self.result.spans[slot].0
}

///|
/// Code-point offset just past where the whole match ends.
pub fn[K : GroupKey] Match::end(
  self : Match,
  group : K,
) -> Int32 raise @parse.Refused {
  let slot = self.slot_of(group.to_group())
  self.result.spans[slot].1
}

///|
/// The `(start, end)` span of a group.
pub fn[K : GroupKey] Match::span(
  self : Match,
  group : K,
) -> (Int32, Int32) raise @parse.Refused {
  (self.start(group), self.end(group))
}

///|
/// The substring the group captured, or `default` (preset `None`) if it took no
/// part — Python's `Match.group`.
pub fn[K : GroupKey] Match::group(
  self : Match,
  group : K,
  default~ : String? = None,
) -> String? raise @parse.Refused {
  let s = self.start(group)
  let e = self.end(group)
  if s < 0 || e < 0 {
    default
  } else {
    Some(slice_cp(self.text, s, e))
  }
}

///|
/// `m[1]` and `m["year"]` — indexing is `group`.
#alias("_[_]")
pub fn[K : GroupKey] Match::item(
  self : Match,
  key : K,
) -> String? raise @parse.Refused {
  self.group(key)
}

///|
/// The `pos` the search started at — Python's `Match.pos`.
pub fn Match::pos(self : Match) -> Int32 {
  self.pos
}

///|
/// The `endpos` the search was bounded by — Python's `Match.endpos`.
pub fn Match::endpos(self : Match) -> Int32 {
  self.endpos
}

///|
/// The `Pattern` that produced this match — Python's `Match.re`.
pub fn Match::re(self : Match) -> Pattern {
  self.pattern
}

///|
/// Whether this match is a partial match — `regex.Match.partial`. True when the
/// pattern was compiled with `partial=true`.
pub fn Match::partial(self : Match) -> Bool {
  self.pattern.partial()
}

///|
/// Expand the Python replacement language in `template` against this match —
/// Python's `Match.expand`.
pub fn Match::expand(
  self : Match,
  template : String,
) -> String raise @parse.Refused {
  expand(template, self)
}

///|
/// Every capture of `group`, as code-point spans; an empty list when the group
/// took no part — Python's `Match.captures`. Our engine records the last span a
/// group held, so this returns the one span a non-repeated group captured.
pub fn[K : GroupKey] Match::captures(
  self : Match,
  group : K,
) -> Array[(Int32, Int32)] raise @parse.Refused {
  let slot = self.slot_of(group.to_group())
  let span = self.result.spans[slot]
  if span.0 >= 0 {
    [span]
  } else {
    []
  }
}

///|
/// Named groups to their captured spans — Python's `Match.capturesdict`.
pub fn Match::capturesdict(self : Match) -> Map[String, Array[(Int32, Int32)]] {
  let index = self.pattern.groupindex()
  let out : Map[String, Array[(Int32, Int32)]] = Map([])
  for k in index.keys() {
    let slots = index.get(k).unwrap()
    let slot : Int32 = slots[0]
    let span = self.result.spans[slot]
    if span.0 >= 0 {
      out[k] = [span]
    }
  }
  out
}

///|
/// The substrings the group captured, or an empty list when it took no part —
/// Python's `Match.slices`.
pub fn[K : GroupKey] Match::slices(
  self : Match,
  group : K,
) -> Array[String] raise @parse.Refused {
  let slot = self.slot_of(group.to_group())
  let span = self.result.spans[slot]
  if span.0 >= 0 {
    [slice_cp(self.text, span.0, span.1)]
  } else {
    []
  }
}

///|
/// The edit counts of a fuzzy match, as `(substitutions, insertions,
/// deletions)`; all zero when the pattern was not fuzzy — Python's
/// `Match.fuzzy_counts`.
pub fn Match::fuzzy_counts(self : Match) -> (Int32, Int32, Int32) {
  match self.result.fuzzy {
    Some((s, i, d)) => (s, i, d)
    None => (0, 0, 0)
  }
}

///|
/// The individual edits of a fuzzy match, as `(substitutions, insertions,
/// deletions)` each a list of `(string_offset, pattern_offset)`; three empty
/// lists when the pattern was not fuzzy — Python's `Match.fuzzy_changes`.
pub fn Match::fuzzy_changes(
  self : Match,
) -> (Array[(Int32, Int32)], Array[(Int32, Int32)], Array[(Int32, Int32)]) {
  match self.result.fuzzy_changes {
    Some(x) => x
    None => ([], [], [])
  }
}

///|
/// The names of the marks `(*MARK:name)` set during the match, in the order
/// they were set — an empty list when none were.
pub fn Match::marks(self : Match) -> Array[String] {
  self.result.marks
}

///|
/// All capturing groups (1..n) as strings, `None` for those that did not take
/// part — Python's `Match.groups`.
pub fn Match::groups(self : Match, default~ : String? = None) -> Array[String?] {
  let ng = self.pattern.groups()
  let out : Array[String?] = []
  let mut g = 1
  while g <= ng {
    let span = self.result.spans[g]
    if span.0 < 0 {
      out.push(default)
    } else {
      out.push(Some(slice_cp(self.text, span.0, span.1)))
    }
    g += 1
  }
  out
}

///|
/// Named groups to their captured strings, `default` filling those that took no
/// part — Python's `Match.groupdict`.
pub fn Match::groupdict(
  self : Match,
  default~ : String? = None,
) -> Map[String, String] {
  let index = self.pattern.groupindex()
  let out : Map[String, String] = Map([])
  for k in index.keys() {
    let slots = index.get(k).unwrap()
    let slot : Int32 = slots[0]
    let span = self.result.spans[slot]
    if span.0 >= 0 {
      out[k] = slice_cp(self.text, span.0, span.1)
    } else if default is Some(d) {
      out[k] = d
    }
  }
  out
}

///|
/// The number of the highest group that took part, or `None` — Python's
/// `Match.lastindex`.
pub fn Match::lastindex(self : Match) -> Int32? {
  let spans = self.result.spans
  let mut i = spans.length() - 1
  while i >= 1 {
    let span = spans[i]
    if span.0 >= 0 {
      return Some(i)
    }
    i -= 1
  }
  None
}

///|
/// The name of the group `lastindex` points at, or `None` — Python's
/// `Match.lastgroup`.
pub fn Match::lastgroup(self : Match) -> String? {
  match self.lastindex() {
    None => None
    Some(idx) => {
      let index = self.pattern.groupindex()
      for k in index.keys() {
        let slots = index.get(k).unwrap()
        let slot : Int32 = slots[0]
        if slot == idx {
          return Some(k)
        }
      }
      None
    }
  }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

///|
fn cp_length(text : String) -> Int32 {
  let mut n = 0
  for c in text {
    ignore(c)
    n += 1
  }
  n
}

///|
/// Slice `text` by code-point offsets (MoonBit strings are UTF-16; the engine
/// reports positions in code points, so the two must not be conflated).
fn slice_cp(text : String, start : Int32, end : Int32) -> String {
  let buf = StringBuilder::StringBuilder()
  let mut i = 0
  for c in text {
    if i >= start && i < end {
      buf.write_char(c)
    }
    i += 1
  }
  buf.to_string()
}

///|
/// Expand the Python replacement language in `repl` against `m`: `\1`..`\99`,
/// `\g`, `\g`, `\\`, and the control escapes.
fn expand(repl : String, m : Match) -> String raise @parse.Refused {
  let chars : Array[Char] = []
  for c in repl {
    chars.push(c)
  }
  let n = chars.length()
  let buf = StringBuilder::StringBuilder()
  let mut i = 0
  while i < n {
    let c = chars[i]
    if c == '\\' && i + 1 < n {
      let d = chars[i + 1]
      if d == 'g' && i + 2 < n && chars[i + 2] == '<' {
        let mut j = i + 3
        let mut name = ""
        while j < n && chars[j] != '>' {
          name = name + chars[j].to_string()
          j += 1
        }
        let key = parse_group_key(name)
        match m.group(key) {
          None => ()
          Some(s) => buf.write_string(s)
        }
        i = if j < n { j + 1 } else { n }
      } else if d >= '0' && d <= '9' {
        let mut j = i + 1
        let mut num = ""
        while j < n && chars[j] >= '0' && chars[j] <= '9' && num.length() < 2 {
          num = num + chars[j].to_string()
          j += 1
        }
        let key = Num(n=decimal_of(num))
        match m.group(key) {
          None => ()
          Some(s) => buf.write_string(s)
        }
        i = j
      } else {
        buf.write_string(escape_char(d))
        i += 2
      }
    } else {
      buf.write_char(c)
      i += 1
    }
  }
  buf.to_string()
}

///|
fn parse_group_key(name : String) -> Group {
  let mut all_digits = name.length() > 0
  for c in name {
    if !(c >= '0' && c <= '9') {
      all_digits = false
    }
  }
  if all_digits {
    Num(n=decimal_of(name))
  } else {
    Name(n=name)
  }
}

///|
fn decimal_of(s : String) -> Int32 {
  let mut v : Int32 = 0
  for c in s {
    v = v * 10 + (c.to_int() - '0'.to_int())
  }
  v
}

///|
fn escape_char(d : Char) -> String {
  if d == 'n' {
    "\n"
  } else if d == 't' {
    "\t"
  } else if d == 'r' {
    "\r"
  } else if d == 'f' {
    "\f"
  } else if d == 'v' {
    '\u{0b}'.to_string()
  } else if d == '\\' {
    "\\"
  } else {
    d.to_string()
  }
}

// ---------------------------------------------------------------------------
// The core library's view of a match
// ---------------------------------------------------------------------------

///|
/// The text before the match — core's `MatchResult::before`.
pub fn Match::before(self : Match) -> StringView {
  let (start, _) = self.result.spans[0]
  slice_cp(self.text, 0, start).view()
}

///|
/// The text after the match — core's `MatchResult::after`.
pub fn Match::after(self : Match) -> StringView {
  let (_, end) = self.result.spans[0]
  slice_cp(self.text, end, cp_length(self.text)).view()
}

///|
/// The matched text — core's `MatchResult::content`, and Python's `group(0)`.
pub fn Match::content(self : Match) -> StringView {
  let (start, end) = self.result.spans[0]
  slice_cp(self.text, start, end).view()
}