///|
// The table of contents, after asciidoctor-pdf's `allocate_toc`, `ink_toc`
// and `ink_toc_level`. Before the body is laid out, a dry run inks the
// titles of the entries with room kept on their right for a page number of
// `toc-max-pagenum-digits` zeros, and that many pages are reserved; after
// the body, the entries are inked on the reserved pages with dot leaders
// and their actual page numbers, all linked to their sections.

///|
/// Where the reserved TOC pages start (page index, cursor y).
priv struct TocExtent {
  from_page : Int
  from_y : Double
  /// the last page reserved
  to_page : Int
}

///|
/// The document's `toclevels` (asciidoctor-pdf's `resolve_toclevels`).
fn resolve_toclevels(doc : @core.Node) -> Int {
  let levels = @string.parse_int(doc.attr("toclevels").unwrap_or("2")) catch {
    _ => 2
  }
  if levels < 1 {
    if doc.doctype() == "book" &&
      doc.sections().iter().any(s => s.sectname == Some("part")) {
      0
    } else {
      1
    }
  } else {
    levels
  }
}

///|
/// A signifier attribute (`chapter-signifier`, `part-signifier`): its
/// value, else `default` unless it was unset.
fn signifier(doc : @core.Node, name : String, default : String) -> String {
  match doc.attr(name) {
    Some(value) => value
    None => if doc.attribute_locked(name) { "" } else { default }
  }
}

///|
/// A section's title as asciidoctor-pdf numbers it (its
/// `Section#numbered_title`): the section number in front when numbered;
/// `formal` adds the part or chapter signifier in a book. The index
/// section has no title while there are no index terms (it is hidden).
fn numbered_title(section : @core.Node, formal? : Bool = false) -> String {
  if section.sectname == Some("index") && session().index.is_empty() {
    return ""
  }
  let doc = section.document()
  let title = section.title().unwrap_or("")
  let sectnumlevels = @string.parse_int(
    doc.attr("sectnumlevels").unwrap_or("3"),
  ) catch {
    _ => 3
  }
  if section.numbered != NotNumbered &&
    section.caption() is None &&
    section.level <= sectnumlevels {
    if doc.doctype() == "book" && section.level == 0 {
      let numbered = "\{section.sectnum(append=Some(":"))} \{title}"
      if formal {
        let s = signifier(doc, "part-signifier", "Part")
        if s == "" {
          numbered
        } else {
          "\{s} \{numbered}"
        }
      } else {
        numbered
      }
    } else if doc.doctype() == "book" && section.level == 1 {
      let numbered = "\{section.sectnum()} \{title}"
      if formal {
        let s = signifier(doc, "chapter-signifier", "Chapter")
        if s == "" {
          numbered
        } else {
          "\{s} \{numbered}"
        }
      } else {
        numbered
      }
    } else {
      "\{section.sectnum()} \{title}"
    }
  } else if section.level == 0 {
    title
  } else {
    section.captioned_title()
  }
}

///|
/// The width Prawn measures for a string (`rendered_width_of_string`):
/// glyph widths in whole thousandths of an em, kerned throughout.
fn Converter::string_width(
  self : Converter,
  text : String,
  style : Style,
) -> Double {
  let face = self.flow.font(style)
  let scale = style.size / 1000.0
  let mut width = 0.0
  let chars = text.iter().collect()
  for i, c in chars {
    width += face.prawn_width(c.to_int()) * scale
    if i + 1 < chars.length() {
      width += face.kerning(c.to_int(), chars[i + 1].to_int()) * scale
    }
  }
  width
}

///|
/// The page label of a physical page (1-based) when the first
/// `front_matter` pages are front matter: its number counted from the body,
/// else roman.
fn page_label(physical : Int, front_matter : Int) -> String {
  let number = physical - front_matter
  if number < 1 {
    roman(physical, false)
  } else {
    number.to_string()
  }
}

///|
/// asciidoctor-pdf's `ink_toc`: the title and the entries, from `y` on page
/// `page`. In a dry run (`self.flow.scratch`) only the titles are inked,
/// narrowed by the room a page number takes; otherwise the entries get dot
/// leaders and page numbers (labelled after `front_matter` pages). Returns
/// the last page it inked on.
fn Converter::ink_toc(
  self : Converter,
  levels : Int,
  page : Int,
  y : Double,
  front_matter : Int,
) -> Int {
  let flow = self.flow
  flow.go_to_page(page)
  flow.y = y
  flow.top = flow.page_top
  match self.doc.attr("toc-title") {
    Some(title) if title != "" => {
      let align = match t_str("toc_title_text_align") {
        Some(a) => alignment_of(a)
        None => self.heading_align(2)
      }
      self.ink_heading(title, 2, align~, category="toc_title", outdent=true)
    }
    _ => ()
  }
  if levels >= 0 {
    flow.margin(t_num_or("toc_margin_top", 0.0))
    self.ink_toc_level(self.doc.sections(), levels, front_matter)
  }
  flow.page
}

///|
/// The TOC's font for entries of `level` (`theme_font :toc, level:`: the
/// `toc_h_*` keys, else the `toc_*` ones) and its line height.
fn toc_font(level : Int) -> (Style, Double) {
  theme_font_heading(base_style(), base_line_height(), level, category="toc")
}

///|
/// The dot leader of the TOC (`toc_dot_leader_*`): its text (default `. `,
/// in `toc_dot_leader_font_color`, style and size), and the levels that
/// have one (None: all).
priv struct DotLeader {
  text : String
  style : Style
  levels : Array[Int]?
}

///|
fn toc_dot_leader() -> DotLeader {
  let (base, _) = theme_font(base_style(), base_line_height(), "toc")
  let (bold, italic) = font_style_of(
    t_str("toc_dot_leader_font_style").unwrap_or("normal"),
  )
  let size = resolve_font_size(tv("toc_dot_leader_font_size"), base.size).unwrap_or(
    base.size,
  )
  let color = t_color("toc_dot_leader_font_color").unwrap_or(base.color)
  let levels = match tv("toc_dot_leader_levels") {
    Str("none") => Some([])
    Null | Str("all") => None
    v =>
      Some(
        v
        .to_ruby_s()
        .split(" ")
        .filter(s => s != "")
        .map(s => @theme.ruby_string_to_i(s.to_owned()).to_int())
        .collect(),
      )
  }
  {
    text: t_str("toc_dot_leader_content").unwrap_or(". "),
    style: {
      ..base,
      size,
      color,
      doc_bold: bold,
      doc_italic: italic,
      bold,
      italic,
      font_set: true,
    },
    levels,
  }
}

///|
fn Converter::ink_toc_level(
  self : Converter,
  entries : Array[@core.Node],
  levels : Int,
  front_matter : Int,
) -> Unit {
  let flow = self.flow
  let hanging_indent = t_num_or("toc_hanging_indent", 0.0)
  let dot_leader = toc_dot_leader()
  for entry in entries {
    let entry_level = entry.level + 1
    let levels_for_entry = match entry.attr("toclevels") {
      Some(v) => @string.parse_int(v) catch { _ => levels }
      None => levels
    }
    if levels_for_entry < entry_level - 1 {
      continue
    }
    if entry.has_option("notitle") &&
      self.doc.blocks.last() is Some(last) &&
      physical_equal(last, entry) &&
      entry.blocks.is_empty() {
      continue
    }
    let title = numbered_title(entry)
    if title == "" {
      continue
    }
    let (style, line_height) = toc_font(entry_level)
    let face = flow.font(style)
    let metrics = line_metrics(line_height, face, style.size)
    let title = drop_anchors(title)
    let title = match style.text_transform {
      Some(transform) => transform_text(title, transform)
      None => title
    }
    let style = { ..style, text_transform: None, }
    let placeholder = self.string_width(
      "0".repeat(toc_max_pagenum_digits(self.doc)),
      style,
    )
    if flow.scratch {
      // the title alone, as prose (justified), narrowed by the page
      // number's room
      flow.indent(0.0, placeholder, fn() {
        flow.typeset(
          parse_formatted(title, style, normalize=false),
          metrics,
          style,
          align=self.text_align,
          hanging_indent~,
          normalize_line_height=true,
        )
      })
    } else {
      let anchor = match entry.attr("pdf-anchor") {
        Some(a) => a
        None => entry.id.unwrap_or("")
      }
      let label = match entry.attr("pdf-page-start") {
        Some(n) =>
          page_label(@string.parse_int(n) catch { _ => 0 }, front_matter)
        None => "?"
      }
      let start_page = flow.page
      let mut start_y = flow.y
      let linked = { ..style, link: Some(Named(pdf_anchor_name(anchor))), }
      let fragments = parse_formatted(title, linked, normalize=false)
      flow.indent(0.0, placeholder, fn() {
        flow.typeset(
          fragments,
          metrics,
          style,
          align=Left,
          hanging_indent~,
          normalize_line_height=true,
        )
      })
      guard flow.last_line is Some((line, line_page, baseline)) else { break }
      // the dots start after the title's last fragment, on its line: Prawn
      // places fragments by their kerned widths
      let mut start_dots = if flow.last_line_first {
        0.0
      } else {
        hanging_indent
      }
      for run in line.runs {
        start_dots += self.string_width(run.text, run.style)
      }
      let last_top = baseline - line.ascender - metrics.padding_top
      if line_page > start_page ||
        last_top - start_y > metrics.leading + style.size {
        start_y = last_top
      }
      let (end_page, end_y) = (flow.page, flow.y)
      flow.go_to_page(line_page)
      flow.y = start_y
      let leader_here = dot_leader.text != "" &&
        (match dot_leader.levels {
          Some(levels) => levels.contains(entry_level - 1)
          None => true
        })
      let label_fragment : Fragment = {
        text: label,
        style: linked,
        anchor: None,
      }
      if leader_here {
        // the dot leader, a quarter-size no-break space, then the page
        // number
        let dot = self.string_width(dot_leader.text, dot_leader.style)
        let spacer_style = { ..style, size: style.size * 0.25, }
        let spacer = self.string_width("\u{a0}", spacer_style)
        let label_width = self.string_width(label, style)
        let count = @cmp.maximum(
          ((flow.width() - start_dots - spacer - label_width) / dot)
          .floor()
          .to_int(),
          0,
        )
        let leader : Array[Fragment] = [
          {
            text: dot_leader.text.repeat(count),
            style: dot_leader.style,
            anchor: None,
          },
          { text: "\u{a0}", style: spacer_style, anchor: None, },
          label_fragment,
        ]
        flow.typeset(leader, metrics, style, align=Right)
      } else {
        flow.typeset([label_fragment], metrics, style, align=Right)
      }
      flow.go_to_page(end_page)
      flow.y = end_y
    }
    if levels_for_entry >= entry_level {
      flow.indent(toc_indent(), 0.0, fn() {
        self.ink_toc_level(entry.sections(), levels_for_entry, front_matter)
      })
    }
  }
}

///|
/// `toc-max-pagenum-digits` (default 3).
fn toc_max_pagenum_digits(doc : @core.Node) -> Int {
  @string.parse_int(doc.attr("toc-max-pagenum-digits").unwrap_or("3")) catch {
    _ => 3
  }
}

///|
/// asciidoctor-pdf's `allocate_toc`: a dry run of the TOC from the cursor
/// (restarted at the top of the next page when nothing of it fits on this
/// one), then the pages it takes are reserved. With `break_after`, the
/// body starts on the page after the TOC; otherwise it continues below it,
/// after the block margin.
fn Converter::allocate_toc(
  self : Converter,
  levels : Int,
  break_after : Bool,
) -> TocExtent {
  let flow = self.flow
  let at_top = flow.at_page_top()
  let run = fn(start_y : Double) {
    self.dry_run(flow.left, flow.right, start_y~, fn(scratch) {
      ignore(scratch.ink_toc(levels, 0, start_y, 0))
      if !break_after {
        scratch.flow.margin(block_margin_bottom())
      }
    })
  }
  let mut scratch = run(flow.y)
  let mut advanced = false
  // stop_if_first_page_empty: nothing fit on the first page
  if !at_top &&
    scratch.model.pages.length() > 1 &&
    scratch.model.pages[0].items.iter().all(i => i is Anchor(_)) {
    advanced = true
    scratch = run(flow.page_top)
  }
  let start_y = if advanced { flow.page_top } else { flow.y }
  let mut pages = scratch.model.pages.length()
  let mut end_y = scratch.y
  // a trailing blank page is dropped: the extent ends at the bottom of the
  // page before
  if pages > 1 && scratch.at_page_top() {
    pages -= 1
    end_y = flow.bottom
  }
  if advanced {
    flow.advance_page()
  }
  let from_page = flow.page
  if break_after {
    for _ in 0.. Bool {
  doc.has_attr("toc") && !doc.sections().is_empty()
}

///|
/// asciidoctor-pdf's `convert_toc`: the TOC where the `toc::[]` macro is
/// (`placement` "macro") or at the end of the preamble ("preamble"), when
/// `toc-placement` asks for it there; one TOC per document.
fn Converter::convert_toc(
  self : Converter,
  node : @core.Node,
  placement : String,
) -> Unit {
  if self.toc_extent is Some(_) {
    return
  }
  let doc = self.doc
  let is_macro = placement == "macro"
  if doc.attr("toc-placement") != Some(placement) || !has_toc(doc) {
    return
  }
  let is_book = doc.doctype() == "book"
  let flow = self.flow
  if is_book {
    // start_toc_page: on a new page, a recto one in a prepress book unless
    // the macro is `nonfacing`
    if !flow.at_page_top() {
      flow.advance_page()
    }
    if self.ppbook &&
      flow.on_verso() &&
      !(is_macro && node.has_option("nonfacing")) {
      flow.advance_page()
    }
  }
  if is_macro {
    let id = node.id.unwrap_or("toc")
    let left = (flow.left * 10000.0).floor() / 10000.0
    let x = if left <= flow.margin_left { 0.0 } else { left }
    flow.add_anchor(id, x, flow.y)
    self.toc_node = Some((node, pdf_anchor_name(id)))
  }
  let title_as_page = is_book || doc.has_attr("title-page")
  let extent = self.allocate_toc(resolve_toclevels(doc), title_as_page)
  self.toc_extent = Some(extent)
  // the index numbers pages from the TOC (or after it) when the page
  // numbering starts there
  if title_as_page {
    match tv("page_numbering_start_at") {
      Str("toc") => session().index.start_page_number = extent.from_page + 1
      Str("after-toc") => {
        // in a prepress book, the recto page after the TOC
        let start = extent.to_page + 2
        session().index.start_page_number = if self.ppbook && start % 2 == 0 {
          start + 1
        } else {
          start
        }
      }
      _ => ()
    }
  }
  if is_macro {
    for periphery in ["header", "footer"] {
      if node.has_option("no" + periphery) {
        let pages = self.disable_running_content[periphery]
        for pgnum in (extent.from_page + 1)..<=(extent.to_page + 1) {
          pages.push(pgnum)
        }
      }
    }
  }
}