///|
/// Counter used to generate unique anchor prefixes so that multiple tables
/// can exist on the same HTML page without conflicts (Python's class-level
/// `HtmlDiff._default_prefix`).
let html_default_prefix : Ref[Int] = { val: 0, }

///|
/// Resets the global anchor-prefix counter shared by all [HtmlDiff]
/// instances (Python's `HtmlDiff._default_prefix = value`). Each call to
/// [HtmlDiff::make_table] or [HtmlDiff::make_file] uses the current value and
/// then increments it.
pub fn HtmlDiff::reset_prefix_counter(value? : Int = 0) -> Unit {
  html_default_prefix.val = value
}

///|
/// Produces an HTML side by side comparison with change highlights.
///
/// The table can be generated in either full or contextual difference mode,
/// either as a complete HTML file ([HtmlDiff::make_file]) or as a table only
/// ([HtmlDiff::make_table]).
///
/// # Example
/// ```mbt check
/// test {
///   @difflib.HtmlDiff::reset_prefix_counter()
///   let table = @difflib.HtmlDiff::new().make_table(["a\n", "b\n"], ["a\n", "c\n"])
///   assert_true(table.contains("b"))
///   assert_true(table.contains("c"))
/// }
/// ```
pub struct HtmlDiff {
  priv tabsize : Int
  priv wrapcolumn : Int?
  priv linejunk : ((String) -> Bool)?
  priv charjunk : ((Char) -> Bool)?
  priv autojunk : Bool
}

///|
/// Constructs an `HtmlDiff`.
///
/// - `tabsize`: tab stop spacing (default 8).
/// - `wrapcolumn`: column where lines are broken and wrapped; by default
///   (or when 0) lines are not wrapped. Panics if negative (Python recurses
///   without bound).
/// - `linejunk`, `charjunk`, `autojunk`: passed to [ndiff] (`charjunk`
///   defaults to [is_character_junk]).
pub fn HtmlDiff::new(
  tabsize? : Int = 8,
  wrapcolumn? : Int,
  linejunk? : (String) -> Bool,
  charjunk? : (Char) -> Bool = is_character_junk,
  autojunk? : Bool = true,
) -> HtmlDiff {
  if wrapcolumn is Some(w) && w < 0 {
    abort("HtmlDiff: wrapcolumn must be >= 0, got \{w}")
  }
  { tabsize, wrapcolumn, linejunk, charjunk: Some(charjunk), autojunk, }
}

///|
/// Returns a complete HTML file containing a side by side comparison table
/// with change highlights.
///
/// - `fromdesc`, `todesc`: column header strings.
/// - `context`: show contextual differences (default `false`: full).
/// - `numlines`: the number of context lines when `context` is set; otherwise
///   how many lines before a change the "next" anchors are placed. Panics if
///   negative.
/// - `charset`: the document charset. Characters that the charset cannot
///   encode are replaced by XML character references (`&#NNN;`). The ascii,
///   latin-1 and UTF-8/16/32 codecs and their Python aliases are recognised;
///   any other name is assumed to cover all of Unicode.
pub fn HtmlDiff::make_file(
  self : HtmlDiff,
  fromlines : Array[String],
  tolines : Array[String],
  fromdesc? : String = "",
  todesc? : String = "",
  context? : Bool = false,
  numlines? : Int = 5,
  charset? : String = "utf-8",
) -> String {
  let html = html_file_template(
    styles=html_styles,
    legend=html_legend,
    table=self.make_table(
      fromlines,
      tolines,
      fromdesc~,
      todesc~,
      context~,
      numlines~,
    ),
    charset~,
  )
  xml_charref_replace(html, charset)
}

///|
/// Returns from/to line lists with tabs expanded and newlines removed.
///
/// Instead of tab characters being replaced by the number of spaces needed to
/// fill in to the next tab stop, the space is filled with tab characters, so
/// that the difference algorithms can identify changes in a file when tabs
/// are replaced by spaces and vice versa. At the end of the HTML generation,
/// the tab characters are replaced with a nonbreakable space.
fn HtmlDiff::expand_tabs(self : HtmlDiff, line : String) -> String {
  // hide real spaces
  let line = line.replace_all(old=" ", new="\u{0}")
  // expand tabs into spaces
  let line = py_expandtabs(line, self.tabsize)
  // replace spaces from expanded tabs back into tab characters
  // (we'll replace them with markup after we do differencing)
  let line = line.replace_all(old=" ", new="\t")
  let line = line.replace_all(old="\u{0}", new=" ")
  // rstrip('\n')
  let mut end = line.length()
  while end > 0 && line[end - 1] == '\n' {
    end -= 1
  }
  line.unsafe_substring(start=0, end~)
}

///|
/// Splits marked-up text at the wrap column, appending the pieces to
/// `data_list`.
fn HtmlDiff::split_line(
  self : HtmlDiff,
  data_list : Array[SideLine],
  line_num : LineNum,
  text : String,
) -> Unit {
  // if blank line or context separator, just add it to the output list
  if !line_num.is_truthy() {
    data_list.push({ num: line_num, text, })
    return
  }
  // if line text doesn't need wrapping, just add it to the output list
  let chars = text.to_array()
  let size = chars.length()
  let max = self.wrapcolumn.unwrap_or(0)
  let markers = chars.iter().filter(c => c == '\u{0}').count()
  if size <= max || size - markers * 3 <= max {
    data_list.push({ num: line_num, text, })
    return
  }
  // scan text looking for the wrap point, keeping track if the wrap
  // point is inside markers
  let mut i = 0
  let mut n = 0
  let mut mark : Char? = None
  while n < max && i < size {
    if chars[i] == '\u{0}' {
      i += 1
      mark = chars.get(i)
      i += 1
    } else if chars[i] == '\u{1}' {
      i += 1
      mark = None
    } else {
      i += 1
      n += 1
    }
  }
  // wrap point is inside text, break it up into separate lines
  let i = @cmp.minimum(i, size)
  let mut line1 = String::from_array(chars[:i])
  let mut line2 = String::from_array(chars[i:])
  // if wrap point is inside markers, place end marker at end of first
  // line and start marker at beginning of second line because each
  // line will have its own table tag markup around it.
  if mark is Some(mark) {
    line1 = line1 + "\u{1}"
    line2 = "\u{0}\{mark}" + line2
  }
  // tack on first line onto the output list
  data_list.push({ num: line_num, text: line1, })
  // use this routine again to wrap the remaining text
  self.split_line(data_list, Cont, line2)
}

///|
/// Splits (wraps) mdiff text lines.
fn HtmlDiff::line_wrapper(
  self : HtmlDiff,
  diffs : Array[MdiffRow],
) -> Array[MdiffRow] {
  let out : Array[MdiffRow] = []
  let padding : SideLine = { num: Blank, text: " ", }
  for row in diffs {
    guard row is Row(fromdata, todata, flag) else {
      // pass context separators through
      out.push(row)
      continue
    }
    // for each from/to line split it at the wrap column to form
    // list of text lines.
    let fromlist : Array[SideLine] = []
    let tolist : Array[SideLine] = []
    self.split_line(fromlist, fromdata.num, fromdata.text)
    self.split_line(tolist, todata.num, todata.text)
    // yield from/to line in pairs inserting blank lines as
    // necessary when one side has more wrapped lines
    for k in 0..<@cmp.maximum(fromlist.length(), tolist.length()) {
      let f = fromlist.get(k).unwrap_or(padding)
      let t = tolist.get(k).unwrap_or(padding)
      out.push(Row(f, t, flag))
    }
  }
  out
}

///|
/// Returns the HTML markup of a "from" / "to" text line.
fn format_line(
  prefix : String,
  flag : Bool,
  linenum : LineNum,
  text : String,
) -> String {
  let id = match linenum {
    Num(n) => " id=\"\{prefix}\{n}\""
    // blank lines where linenum is '>' or ''
    _ => ""
  }
  // replace those things that would get confused with HTML symbols
  let text = text
    .replace_all(old="&", new="&")
    .replace_all(old=">", new=">")
    .replace_all(old="<", new="<")
  // make space non-breakable so they don't get compressed or line wrapped
  let text = py_rstrip(text.replace_all(old=" ", new=" "))
  // add a class to the td tag if there is a difference on the line
  let css_class = if flag { " class=\"diff_changed\" " } else { " " }
  "\{linenum.to_column()}" +
  "\{text}"
}

///|
/// Returns an HTML table of a side by side comparison with change
/// highlights.
///
/// The arguments have the same meaning as for [HtmlDiff::make_file].
pub fn HtmlDiff::make_table(
  self : HtmlDiff,
  fromlines : Array[String],
  tolines : Array[String],
  fromdesc? : String = "",
  todesc? : String = "",
  context? : Bool = false,
  numlines? : Int = 5,
) -> String {
  if numlines < 0 {
    // Python fails with IndexError / ZeroDivisionError here.
    abort("HtmlDiff: numlines must be >= 0, got \{numlines}")
  }
  // make unique anchor prefixes so that multiple tables may exist
  // on the same page without conflict.
  let fromprefix = "from\{html_default_prefix.val}_"
  let toprefix = "to\{html_default_prefix.val}_"
  html_default_prefix.val += 1
  // change tabs to spaces before it gets more difficult after we insert
  // markup; this also removes trailing newlines (see gh-71896)
  let fromlines = fromlines.map(line => self.expand_tabs(line))
  let tolines = tolines.map(line => self.expand_tabs(line))
  // create diffs which generate side by side from/to data
  let context_lines = if context { Some(numlines) } else { None }
  let mut diffs = mdiff(
    fromlines,
    tolines,
    context?=context_lines,
    linejunk?=self.linejunk,
    charjunk?=self.charjunk,
    autojunk=self.autojunk,
  )
  // wrap lines that exceed desired width
  if self.wrapcolumn is Some(w) && w != 0 {
    diffs = self.line_wrapper(diffs)
  }
  // collect up from/to lines and flags into lists (also format the lines)
  let mut fromlist : Array[String?] = []
  let mut tolist : Array[String?] = []
  let mut flaglist : Array[Bool?] = []
  for row in diffs {
    match row {
      Row(fromdata, todata, flag) => {
        fromlist.push(
          Some(format_line(fromprefix, flag, fromdata.num, fromdata.text)),
        )
        tolist.push(Some(format_line(toprefix, flag, todata.num, todata.text)))
        flaglist.push(Some(flag))
      }
      Separator => {
        fromlist.push(None)
        tolist.push(None)
        flaglist.push(None)
      }
    }
  }
  // process change flags, generating middle column of next anchors/links
  let mut next_id = Array::make(flaglist.length(), "")
  let mut next_href = Array::make(flaglist.length(), "")
  let mut num_chg = 0
  let mut in_change = false
  let mut last = 0
  for i, flag in flaglist {
    if flag is Some(true) {
      if !in_change {
        in_change = true
        last = i
        // at the beginning of a change, drop an anchor a few lines
        // (the context lines) before the change for the previous link
        let anchor = @cmp.maximum(0, i - numlines)
        next_id[anchor] = " id=\"difflib_chg_\{toprefix}_\{num_chg}\""
        // at the beginning of a change, drop a link to the next change
        num_chg += 1
        next_href[last] = "n"
      }
    } else {
      in_change = false
    }
  }
  // check for cases where there is no content to avoid exceptions
  if flaglist.is_empty() {
    flaglist = [Some(false)]
    next_id = [""]
    next_href = [""]
    last = 0
    let only = if context {
      " No Differences Found "
    } else {
      " Empty File "
    }
    fromlist = [Some(only)]
    tolist = [Some(only)]
  }
  // if not a change on first line, drop a link
  if !(flaglist[0] is Some(true)) {
    next_href[0] = "f"
  }
  // redo the last link to link to the top
  next_href[last] = "t"
  let s = StringBuilder()
  for i, flag in flaglist {
    match flag {
      None =>
        // mdiff yields None on separator lines; skip the bogus ones
        // generated for the first line
        if i > 0 {
          s.write_string("                \n        \n")
        }
      Some(_) => {
        let from_cell = fromlist[i].unwrap_or("")
        let to_cell = tolist[i].unwrap_or("")
        s.write_string(
          "            \{next_href[i]}\{from_cell}",
        )
        s.write_string(
          "\{next_href[i]}\{to_cell}\n",
        )
      }
    }
  }
  let header_row = if fromdesc != "" || todesc != "" {
    "" +
    "
" + "\{fromdesc}" + "
" + "\{todesc}" + "" } else { "" } let table = html_table_template( data_rows=s.to_string(), header_row~, prefix=toprefix, ) table .replace_all(old="\u{0}+", new="") .replace_all(old="\u{0}-", new="") .replace_all(old="\u{0}^", new="") .replace_all(old="\u{1}", new="") .replace_all(old="\t", new=" ") }