///|
// The index: asciidoctor-pdf's IndexCatalog (lib/asciidoctor/pdf/
// index_catalog.rb). Every index term met while converting inline content
// (`((term))`, `(((a, b, c)))`, `indexterm:[]`, `indexterm2:[]`) is
// anchored where it occurs (`__indexterm-N`) and stored under its category
// (the upper-cased first letter, `@` for anything else) with the page the
// anchor lands on, which the `[index]` section lists.

///|
/// One occurrence of a term: its anchor and, once inked, the page label
/// and physical page number (`dest[:page]`, `dest[:page_sortable]`).
priv struct IndexDest {
  anchor : String
  mut page : String?
  mut page_sortable : Int
}

///|
/// A term, a category (the terms under a letter) or an unresolved `see`
/// target: asciidoctor-pdf's IndexTermGroup and its subclasses.
priv struct IndexTerm {
  /// the formatted-text markup of the name
  markup : String
  /// the plain text of the name (FormattedString#to_s), which orders and
  /// matches terms
  name : String
  /// subterms (or a category's terms), by markup, in insertion order
  terms : Map[String, IndexTerm]
  dests : Array[IndexDest]
  /// the `see` and `see-also` associations, as markup
  mut see_markup : String?
  see_also_markup : Array[String]
  /// resolved associations: a term, or an unresolved name (no anchor)
  mut see : IndexTerm?
  mut see_also : Array[IndexTerm]
  /// whether this is a term (it has an anchor) rather than an unresolved
  /// name or a category
  is_term : Bool
}

///|
fn IndexTerm::new(markup : String, is_term? : Bool = true) -> IndexTerm {
  {
    markup,
    name: formatted_plain_text(markup),
    terms: Map([]),
    dests: [],
    see_markup: None,
    see_also_markup: [],
    see: None,
    see_also: [],
    is_term,
  }
}

///|
/// The text of formatted markup, as the fragments asciidoctor-pdf's
/// `parse_text ... normalize: true` yields would spell it.
fn formatted_plain_text(markup : String) -> String {
  let sb = StringBuilder()
  for fragment in parse_formatted(markup, Style::new()) {
    sb.write_string(fragment.text)
  }
  sb.to_string()
}

///|
/// The destination name of a term's own entry in the index.
fn IndexTerm::anchor(self : IndexTerm) -> String {
  "__indextermdef-\{md5_hex(@utf8.encode(self.name))}"
}

///|
/// `IndexTermGroup#store_term`
fn IndexTerm::store_term(
  self : IndexTerm,
  markup : String,
  dest : IndexDest?,
  see? : String? = None,
  see_also? : Array[String] = [],
) -> IndexTerm {
  let term = match self.terms.get(markup) {
    Some(term) => term
    None => {
      let term = IndexTerm::new(markup)
      self.terms[markup] = term
      term
    }
  }
  match dest {
    Some(d) => term.dests.push(d)
    None => ()
  }
  if term.see_markup is None && see is Some(_) {
    term.see_markup = see
  }
  term.see_also_markup.append(see_also)
  term
}

///|
/// Ruby's `IndexTermGroup#<=>`: case-insensitive (ASCII) first, then by
/// bytes.
fn compare_index_names(a : String, b : String) -> Int {
  let fold = fn(s : String) -> String {
    let sb = StringBuilder()
    for c in s {
      sb.write_char(
        if c >= 'A' && c <= 'Z' {
          (c.to_int() + 32).unsafe_to_char()
        } else {
          c
        },
      )
    }
    sb.to_string()
  }
  let c = compare_bytes(@utf8.encode(fold(a)), @utf8.encode(fold(b)))
  if c != 0 {
    c
  } else {
    compare_bytes(@utf8.encode(a), @utf8.encode(b))
  }
}

///|
fn compare_bytes(a : Bytes, b : Bytes) -> Int {
  let n = @cmp.minimum(a.length(), b.length())
  for i in 0.. Array[IndexTerm] {
  let terms = self.terms.values().collect()
  terms.sort_by((a, b) => compare_index_names(a.name, b.name))
  terms
}

///|
/// `IndexTerm#dests`: the occurrences inked so far, by page.
fn IndexTerm::paged_dests(self : IndexTerm) -> Array[IndexDest] {
  let dests = self.dests.filter(d => d.page is Some(_))
  dests.sort_by((a, b) => a.page_sortable - b.page_sortable)
  dests
}

///|
/// `IndexTerm#container?`: no occurrence of its own on any page.
fn IndexTerm::is_container(self : IndexTerm) -> Bool {
  self.dests.iter().all(d => d.page is None)
}

///|
priv struct IndexCatalog {
  categories : Map[String, IndexTerm]
  dests : Map[String, IndexDest]
  mut sequence : Int
  mut start_page_number : Int
}

///|
fn IndexCatalog::new() -> IndexCatalog {
  { categories: Map([]), dests: Map([]), sequence: 0, start_page_number: 1, }
}

///|
fn IndexCatalog::next_anchor_name(self : IndexCatalog) -> String {
  self.sequence += 1
  "__indexterm-\{self.sequence}"
}

///|
/// Ruby's `/^\p{Alpha}/` on an upper-cased first character, for the
/// scripts the default fonts cover.
fn is_alpha_char(c : Char) -> Bool {
  let cp = c.to_int()
  (cp >= 'A'.to_int() && cp <= 'Z'.to_int()) ||
  (cp >= 'a'.to_int() && cp <= 'z'.to_int()) ||
  cp == 0xAA ||
  cp == 0xB5 ||
  cp == 0xBA ||
  (cp >= 0xC0 && cp <= 0x24F && cp != 0xD7 && cp != 0xF7) ||
  (
    cp >= 0x370 &&
    cp <= 0x3FF &&
    cp != 0x375 &&
    cp != 0x37E &&
    cp != 0x384 &&
    cp != 0x385 &&
    cp != 0x387
  ) ||
  (cp >= 0x400 && cp <= 0x481) ||
  (cp >= 0x48A && cp <= 0x52F) ||
  (cp >= 0x3040 && cp <= 0x30FF && cp != 0x30A0 && cp != 0x30FB) ||
  (cp >= 0x4E00 && cp <= 0x9FFF) ||
  (cp >= 0xAC00 && cp <= 0xD7A3)
}

///|
/// Ruby's `String#upcase` of one character, for Latin, Greek and Cyrillic.
fn upcase_char(c : Char) -> Char {
  let cp = c.to_int()
  let upper = if cp >= 'a'.to_int() && cp <= 'z'.to_int() {
    cp - 32
  } else if cp >= 0xE0 && cp <= 0xFE && cp != 0xF7 {
    cp - 32
  } else if cp == 0xFF {
    0x178
  } else if cp >= 0x100 &&
    cp <= 0x17F &&
    cp != 0x131 &&
    cp != 0x138 &&
    cp != 0x149 &&
    cp != 0x17F {
    // alternating pairs (with the odd stretch 0x139..0x148, 0x179..0x17E)
    if (cp >= 0x139 && cp <= 0x148) || (cp >= 0x179 && cp <= 0x17E) {
      if cp % 2 == 0 {
        cp - 1
      } else {
        cp
      }
    } else if cp % 2 == 1 {
      cp - 1
    } else {
      cp
    }
  } else if cp >= 0x3B1 && cp <= 0x3C9 && cp != 0x3C2 {
    cp - 32
  } else if cp >= 0x430 && cp <= 0x44F {
    cp - 32
  } else if cp >= 0x450 && cp <= 0x45F {
    cp - 80
  } else {
    cp
  }
  upper.unsafe_to_char()
}

///|
/// `init_category`
fn IndexCatalog::init_category(self : IndexCatalog, name : String) -> IndexTerm {
  let first = match name.get_char(0) {
    Some(c) => upcase_char(c)
    None => '@'
  }
  let key = if is_alpha_char(first) { first.to_string() } else { "@" }
  match self.categories.get(key) {
    Some(category) => category
    None => {
      let category = IndexTerm::new(key, is_term=false)
      self.categories[key] = category
      category
    }
  }
}

///|
/// `store_term`: a primary, secondary or tertiary term (the names are
/// markup), occurring at `dest`.
fn IndexCatalog::store_term(
  self : IndexCatalog,
  names : Array[String],
  dest : IndexDest,
  see? : String? = None,
  see_also? : Array[String] = [],
) -> Unit {
  if names.is_empty() {
    return
  }
  self.dests[dest.anchor] = dest
  let primary = names[0]
  let category = self.init_category(formatted_plain_text(primary))
  match names.length() {
    1 => ignore(category.store_term(primary, Some(dest), see~, see_also~))
    2 =>
      ignore(
        category
        .store_term(primary, None)
        .store_term(names[1], Some(dest), see~, see_also~),
      )
    _ =>
      ignore(
        category
        .store_term(primary, None)
        .store_term(names[1], None)
        .store_term(names[2], Some(dest), see~, see_also~),
      )
  }
}

///|
/// `link_dest_to_page`: the anchor `anchor` was inked on physical page
/// `physical` (1-based).
fn IndexCatalog::link_dest_to_page(
  self : IndexCatalog,
  anchor : String,
  physical : Int,
) -> Unit {
  match self.dests.get(anchor) {
    Some(dest) => {
      dest.page_sortable = physical
      let virtual_number = physical - (self.start_page_number - 1)
      dest.page = Some(
        if virtual_number < 1 {
          roman(physical, false)
        } else {
          virtual_number.to_string()
        },
      )
    }
    None => ()
  }
}

///|
/// `find_primary_term`: a term of any category whose name reads `name`.
fn IndexCatalog::find_primary_term(
  self : IndexCatalog,
  name : String,
) -> IndexTerm? {
  for category in self.categories.values() {
    for term in category.terms.values() {
      if term.name == name {
        return Some(term)
      }
    }
  }
  None
}

///|
/// `link_associations`: resolve `see` and `see also` names to terms.
fn IndexCatalog::link_associations(self : IndexCatalog) -> Unit {
  fn resolve(markup : String) -> IndexTerm {
    match self.find_primary_term(formatted_plain_text(markup)) {
      Some(term) => term
      None => IndexTerm::new(markup, is_term=false)
    }
  }

  fn walk(group : IndexTerm) -> Unit {
    for term in group.terms.values() {
      match term.see_markup {
        Some(see) => term.see = Some(resolve(see))
        None =>
          if !term.see_also_markup.is_empty() {
            term.see_also = term.see_also_markup.map(resolve)
          }
      }
      if !term.terms.is_empty() {
        walk(term)
      }
    }
  }

  for category in self.categories.values() {
    walk(category)
  }
}

///|
fn IndexCatalog::is_empty(self : IndexCatalog) -> Bool {
  self.categories.is_empty()
}

///|
fn IndexCatalog::sorted_categories(self : IndexCatalog) -> Array[IndexTerm] {
  let categories = self.categories.values().collect()
  categories.sort_by((a, b) => compare_index_names(a.name, b.name))
  categories
}

///|
/// asciidoctor-pdf's `convert_index_section`: the categories in columns
/// (`index_columns()`, unless already in a column box).
fn Converter::convert_index_section(self : Converter) -> Unit {
  let index = session().index
  index.link_associations()
  let flow = self.flow
  if flow.columns is Some(_) || index_columns() < 2 {
    self.convert_index_categories()
  } else {
    flow.column_box(index_columns(), index_column_gap(), () => {
      self.convert_index_categories()
    })
  }
}

///|
/// `convert_index_categories`: each category's letter, in bold, then its
/// terms; a category moves to the next column when its letter and a line
/// would not fit.
fn Converter::convert_index_categories(self : Converter) -> Unit {
  let flow = self.flow
  let base = Style::new()
  let face = flow.font(base)
  let metrics = line_metrics(base_line_height(), face, base.size)
  let space_needed = description_list_term_spacing() +
    2.0 *
    flow.height_of([{ text: "A", style: base, anchor: None, }], metrics, base)
  for category in session().index.sorted_categories() {
    if space_needed > flow.cursor() {
      flow.advance_page()
    }
    // plain text: the letter is not formatted; in the term font style
    let style = match t_str("description_list_term_font_style") {
      Some(fs) => {
        let (doc_bold, doc_italic) = font_style_of(fs)
        { ..Style::new(), doc_bold, doc_italic, }
      }
      None => Style::new()
    }
    self.ink_prose_fragments(
      [{ text: category.name, style, anchor: None, }],
      style,
      margin_bottom=description_list_term_spacing(),
    )
    for term in category.sorted_terms() {
      self.convert_index_term(term)
    }
    if prose_margin_bottom() > flow.cursor() {
      flow.advance_page()
    } else {
      flow.move_down(prose_margin_bottom())
    }
  }
}

///|
/// Ink fragments as prose, left-aligned, with a bottom margin.
fn Converter::ink_prose_fragments(
  self : Converter,
  fragments : Array[Fragment],
  style : Style,
  margin_bottom? : Double = 0.0,
  hanging_indent? : Double = 0.0,
) -> Unit {
  let flow = self.flow
  let metrics = line_metrics(base_line_height(), flow.font(style), style.size)
  flow.typeset(fragments, metrics, style, align=Left, hanging_indent~)
  flow.margin(margin_bottom)
}

///|
/// A term's page numbers and what each links to, after
/// `index-pagenum-sequence-style`: every occurrence (the default, `term`),
/// each page once (`page`), or runs of consecutive pages as ranges
/// (`range`, `consolidate_ranges`), each linked to its first occurrence.
fn index_pagenums(term : IndexTerm, style : String?) -> Array[(String, String)] {
  let dests = term.paged_dests()
  match style {
    Some("page") | Some("range") => {
      // the first occurrence on each page
      let pages : Array[(String, String)] = []
      for dest in dests {
        let label = dest.page.unwrap_or("")
        if !pages.iter().any(p => p.0 == label) {
          pages.push((label, dest.anchor))
        }
      }
      if style == Some("page") || pages.length() < 2 {
        return pages
      }
      let to_i = fn(label : String) {
        @string.parse_int(label) catch {
          _ => 0
        }
      }
      let ranges : Array[(String, String, String)] = [] // first, last, anchor
      for page in pages {
        match ranges.last() {
          Some((first, last, anchor)) if to_i(last) + 1 == to_i(page.0) =>
            ranges[ranges.length() - 1] = (first, page.0, anchor)
          _ => ranges.push((page.0, page.0, page.1))
        }
      }
      ranges.map(r => {
        let (first, last, anchor) = r
        (if first == last { first } else { "\{first}-\{last}" }, anchor)
      })
    }
    _ => dests.map(d => (d.page.unwrap_or(""), d.anchor))
  }
}

///|
/// Merge adjacent fragments of the same style (`consolidate_fragments`).
fn consolidate_fragments(fragments : Array[Fragment]) -> Array[Fragment] {
  let result : Array[Fragment] = []
  for fragment in fragments {
    match result.last() {
      Some(last) if last.anchor is None &&
        fragment.anchor is None &&
        last.style == fragment.style =>
        result[result.length() - 1] = {
          ..last,
          text: last.text + fragment.text,
        }
      _ => result.push(fragment)
    }
  }
  result
}

///|
/// `convert_index_term`: the term, anchored for `see` links, then its
/// pages (every occurrence, linked to it) or what to see instead, with a
/// hanging indent; then its `see also` lines and its subterms, indented.
fn Converter::convert_index_term(self : Converter, term : IndexTerm) -> Unit {
  let flow = self.flow
  let base = Style::new()
  // for print (`media` other than screen) nothing is linked, and the pages
  // are consolidated into ranges
  let screen = self.doc.attr("media").unwrap_or("screen") == "screen"
  let linked = fn(anchor : String) -> Style {
    if !screen {
      return base
    }
    apply_text_category(
      { ..base, link: Some(Named(pdf_anchor_name(anchor))), },
      "link",
      decoration=true,
    )
  }
  let fragments = parse_formatted(term.markup, base)
  let see_also : Array[Array[Fragment]] = []
  if !term.is_container() {
    if screen {
      fragments.insert(0, {
        text: "",
        style: base,
        anchor: Some(term.anchor()),
      })
    }
    match term.see {
      Some(see) => {
        fragments.push({ text: " (see ", style: base, anchor: None, })
        let style = if see.is_term { linked(see.anchor()) } else { base }
        fragments.append(parse_formatted(see.markup, style))
        fragments.push({ text: ")", style: base, anchor: None, })
      }
      None => {
        for
          pagenum in index_pagenums(
            term,
            if screen {
              self.doc.attr("index-pagenum-sequence-style")
            } else {
              Some("range")
            },
          ) {
          let (label, anchor) = pagenum
          fragments.push({ text: ", ", style: base, anchor: None, })
          fragments.push({ text: label, style: linked(anchor), anchor: None, })
        }
        let also = term.see_also.copy()
        also.sort_by((a, b) => compare_index_names(a.name, b.name))
        for also_term in also {
          let style = if also_term.is_term {
            linked(also_term.anchor())
          } else {
            base
          }
          let item = [{ text: "(see also ", style: base, anchor: None, }]
          item.append(parse_formatted(also_term.markup, style))
          item.push({ text: ")", style: base, anchor: None, })
          see_also.push(item)
        }
      }
    }
  }
  let hanging = description_list_description_indent() * 2.0
  self.ink_prose_fragments(
    consolidate_fragments(fragments),
    base,
    hanging_indent=hanging,
  )
  if !see_also.is_empty() || !term.terms.is_empty() {
    flow.indent(description_list_description_indent(), 0.0, () => {
      for item in see_also {
        self.ink_prose_fragments(
          consolidate_fragments(item),
          base,
          hanging_indent=hanging,
        )
      }
      for subterm in term.sorted_terms() {
        self.convert_index_term(subterm)
      }
    })
  }
}