///|
/// A single chapter link in a table of contents.
pub(all) struct SummaryLink {
  title : String
  location : String
  source : String
  /// Nested sub-chapters under this chapter, if any.
  nested_items : Array[SummaryItem]
} derive(Eq, Debug)

///|
/// A single entry in a table of contents.
pub(all) enum SummaryItem {
  /// A link to a chapter.
  Link(SummaryLink)
  /// A part title, grouping subsequent chapters under a heading.
  PartTitle(String)
  /// A visual separator between groups of chapters.
  Separator
} derive(Eq, Debug)

///|
/// The parsed structure of a book's table of contents (`SUMMARY.md`),
/// split into an optional prefix section, the main numbered chapters, and
/// an optional suffix section — mirroring the conventional structure of
/// an mdBook-style summary file.
pub(all) struct Summary {
  title : String
  prefix_chapters : Array[SummaryItem]
  numbered_chapters : Array[SummaryItem]
  suffix_chapters : Array[SummaryItem]
} derive(Eq, Debug)

///|
/// A single article's indexed content, e.g. for building a client-side
/// search index over the site's pages.
pub(all) struct ArticleIndex {
  title : String
  location : String
  content : String
} derive(Eq, Debug, ToJson)

///|
fn flatten_items(
  items : Array[SummaryItem],
  depth : Int,
  current : String,
  base_url : String,
  numbered? : Bool = false,
  prefix? : String = "",
) -> Array[Json] {
  let result : Array[Json] = []
  let mut counter = 0
  for item in items {
    match item {
      Separator => result.push({ "is_separator": true.to_json() })
      PartTitle(title) =>
        result.push({
          "is_part_title": true.to_json(),
          "title": title.to_json(),
        })
      Link(link) => {
        counter += 1
        let number = prefix + counter.to_string()
        let location = if base_url[-1:] == "/" {
          base_url[0:base_url.length() - 1] + link.location
        } else {
          base_url + link.location
        }
        if numbered {
          result.push({
            "is_link": true.to_json(),
            "title": link.title.to_json(),
            "location": location,
            "source": link.source,
            "depth": depth.to_json(),
            "is_current": (link.location == current).to_json(),
            "number": number.to_json(),
          })
        } else {
          result.push({
            "is_link": true.to_json(),
            "title": link.title.to_json(),
            "location": location,
            "source": link.source,
            "depth": depth.to_json(),
            "is_current": (link.location == current).to_json(),
          })
        }
        result.append(
          flatten_items(
            link.nested_items,
            depth + 1,
            current,
            base_url,
            numbered~,
            prefix=number + ".",
          ),
        )
      }
    }
  }
  result
}

///|
/// Returns a JSON representation of this summary, rooted at `location`,
/// e.g. for use as a template variable describing the current section's
/// navigation structure.
pub fn Summary::flatten(
  self : Summary,
  current : String,
  base_url : String,
) -> Json {
  let current = unify_url(current)
  {
    "title": self.title.to_json(),
    "prefix_chapters": Json::array(
      flatten_items(self.prefix_chapters, 0, current, base_url),
    ),
    "numbered_chapters": Json::array(
      flatten_items(self.numbered_chapters, 0, current, base_url, numbered=true),
    ),
    "suffix_chapters": Json::array(
      flatten_items(self.suffix_chapters, 0, current, base_url),
    ),
  }
}

///|
fn parse_summary_item(item : Array[@markdown.Block]) -> SummaryItem? {
  let link : SummaryLink? = extract_item_link(item)
  let nested : Array[SummaryItem] = extract_nested_items(item)

  link.map(link => {
    Link({
      title: link.title,
      location: link.location,
      source: link.source,
      nested_items: nested,
    })
  })
}

///|
fn extract_item_link(item : Array[@markdown.Block]) -> SummaryLink? {
  match item {
    [Paragraph(inlines), ..] =>
      match inlines {
        [Link(title, url), ..] =>
          Some({
            title: @markdown.Plainable::to_plain(title),
            location: unify_url(url),
            source: url,
            nested_items: [],
          })
        _ => None
      }
    _ => None
  }
}

///|
fn extract_nested_items(item : Array[@markdown.Block]) -> Array[SummaryItem] {
  for block in item {
    match block {
      List(lb) => return parse_list_items(lb.items)
      _ => continue
    }
  } nobreak {
    []
  }
}

///|
fn parse_list_items(
  items : Array[Array[@markdown.Block]],
) -> Array[SummaryItem] {
  let result : Array[SummaryItem] = []
  for item in items {
    match parse_summary_item(item) {
      Some(si) => result.push(si)
      None => continue
    }
  }
  result
}

///|
/// Parses a book's `SUMMARY.md` (already parsed into a Markdown AST) into
/// its structured `Summary` representation.
pub fn parse_summary(doc : @markdown.Markdown) -> Summary {
  let title : String = find_title(doc)

  let prefix_chapters : Array[SummaryItem] = []
  let numbered_chapters : Array[SummaryItem] = []
  let suffix_chapters : Array[SummaryItem] = []

  let blocks = skip_title(doc)

  let mut phase = 0 // 0=prefix, 1=numbered, 2=suffix
  let pending_headings : Array[String] = []

  fn target() -> Array[SummaryItem] {
    if phase == 0 {
      prefix_chapters
    } else if phase == 1 {
      numbered_chapters
    } else {
      suffix_chapters
    }
  }

  fn flush_headings() -> Unit {
    for h in pending_headings {
      target().push(PartTitle(h))
    }
    pending_headings.clear()
  }

  for block in blocks {
    match block {
      Heading(_, inlines) =>
        pending_headings.push(@markdown.Plainable::to_plain(inlines))
      ThematicBreak => {
        flush_headings()
        target().push(Separator)
      }
      List(lb) => {
        if phase == 0 {
          phase = 1
        }
        flush_headings()
        for item in parse_list_items(lb.items) {
          numbered_chapters.push(item)
        }
      }
      Paragraph(inlines) => {
        flush_headings()
        if phase == 1 {
          phase = 2
        }
        for item in extract_root_link_items(inlines) {
          target().push(item)
        }
      }
      _ => ()
    }
  }

  { title, prefix_chapters, numbered_chapters, suffix_chapters }
}

///|
fn unify_url(url : String) -> String {
  let url = url.replace_all(old="\\", new="/")
  let path = @path.Path(url)
  let basename = Show::to_string(path.basename())
  let result = path
    .dirname()
    .join(
      basename
      .rev_find(".")
      .map(index => Show::to_string(basename[:index]))
      .unwrap_or(basename) +
      ".html",
    )
    |> Show::to_string
  if result[0:2] == "./" {
    "/" + (result[2:] |> Show::to_string)
  } else if result[0:1] == "/" {
    result
  } else {
    "/" + result
  }
}

///|
fn find_title(doc : @markdown.Markdown) -> String {
  for block in doc {
    match block {
      Heading(1, inlines) => return @markdown.Plainable::to_plain(inlines)
      _ => continue
    }
  } nobreak {
    "Summary"
  }
}

///|
fn skip_title(doc : @markdown.Markdown) -> Array[@markdown.Block] {
  let mut found = false
  let result : Array[@markdown.Block] = []

  for block in doc {
    if !found {
      match block {
        Heading(1, _) => {
          found = true
          continue
        }
        _ => result.push(block)
      }
    } else {
      result.push(block)
    }
  }

  result
}

///|
fn extract_root_link_items(
  inlines : Array[@markdown.Inline],
) -> Array[SummaryItem] {
  let result : Array[SummaryItem] = []
  for inline in inlines {
    match inline {
      Link(title, url) =>
        result.push(
          Link({
            title: @markdown.Plainable::to_plain(title),
            location: unify_url(url),
            source: url,
            nested_items: [],
          }),
        )
      _ => continue
    }
  }
  result
}

///|
/// Renders a Markdown string to its plain-text representation (stripping
/// all Markdown/HTML markup), e.g. for use in generating search indexes
/// or previews from summary/article content.
pub fn md_to_plain(input : String) -> String {
  let out = StringBuilder::new()
  let mut i = 0
  while i < input.length() {
    let ch = input[i]
    if ch == '#' {
      while i < input.length() && input[i] == '#' {
        i = i + 1
      }
      while i < input.length() && input[i] == ' ' {
        i = i + 1
      }
    } else if ch == '*' || ch == '_' {
      let marker = ch
      if i + 1 < input.length() && input[i + 1] == marker {
        i = i + 2
      } else {
        i = i + 1
      }
    } else if ch == '`' {
      i = i + 1
    } else if ch == '!' && i + 1 < input.length() && input[i + 1] == '[' {
      i = i + 2
      while i < input.length() && input[i] != ']' {
        i = i + 1
      }
      i = i + 1
      if i < input.length() && input[i] == '(' {
        while i < input.length() && input[i] != ')' {
          i = i + 1
        }
        i = i + 1
      }
    } else if ch == '[' {
      i = i + 1
      while i < input.length() && input[i] != ']' {
        if input[i].to_char() is Some(ch) {
          out.write_char(ch)
        }
        i = i + 1
      }
      i = i + 1
      if i < input.length() && input[i] == '(' {
        while i < input.length() && input[i] != ')' {
          i = i + 1
        }
        i = i + 1
      }
    } else if ch == '<' {
      while i < input.length() && input[i] != '>' {
        i = i + 1
      }
      i = i + 1
    } else if ch == '>' && (i == 0 || input[i - 1] == '\n') {
      i = i + 1
      if i < input.length() && input[i] == ' ' {
        i = i + 1
      }
    } else if ch == '|' {
      out.write_char(' ')
      i = i + 1
    } else {
      if ch.to_char() is Some(ch) {
        out.write_char(ch)
      }
      i = i + 1
    }
  }
  out
  .to_string()
  .replace_all(old="\n", new=" ")
  .replace_all(old="---", new=" ")
  .to_lower()
  .trim()
  .to_owned()
}

///|
/// Builds a search/content index over all articles reachable from this
/// summary, reading each article's source file from `src`.
///
/// Raises if any referenced article's source file cannot be read.
pub fn Summary::index_articles(
  self : Summary,
  src : String,
) -> Array[ArticleIndex] raise {
  let articles : Array[ArticleIndex] = []

  fn collect(items : Array[SummaryItem]) -> Unit raise {
    for item in items {
      match item {
        SummaryItem::Link(link) => {
          let path = @path.Path(src).join(link.source) |> Show::to_string
          articles.push({
            title: link.title,
            location: link.location,
            content: path |> @fs.read_file_to_string |> md_to_plain,
          })
          collect(link.nested_items)
        }
        _ => ()
      }
    }
  }

  collect(
    [..self.prefix_chapters, ..self.numbered_chapters, ..self.suffix_chapters],
  )
  articles
}

///|
/// Returns a breadcrumb string describing the path from the root of the
/// table of contents down to the chapter located at `location`.
pub fn Summary::breadcrumb(self : Summary, current : String) -> String {
  let current = unify_url(current)
  fn find(items : Array[SummaryItem], path : Array[String]) -> Array[String]? {
    for item in items {
      match item {
        SummaryItem::Link(link) => {
          let new_path = path + [link.title]
          if link.location == current {
            return Some(new_path)
          }
          match find(link.nested_items, new_path) {
            Some(p) => return Some(p)
            None => ()
          }
        }
        _ => ()
      }
    }
    None
  }

  let path = match find(self.prefix_chapters, []) {
    Some(p) => p
    None =>
      match find(self.numbered_chapters, []) {
        Some(p) => p
        None =>
          match find(self.suffix_chapters, []) {
            Some(p) => p
            None => return "/ ???"
          }
      }
  }
  "/ " + path.join(" / ")
}

///|
/// Returns the previous and next chapter (as `(title, location)` pairs),
/// relative to the chapter located at `location`, according to this
/// summary's chapter ordering. Either value is `None` if there is no
/// previous/next chapter (e.g. at the start or end of the book).
pub fn Summary::prev_next(
  self : Summary,
  current : String,
) -> ((String, String)?, (String, String)?) {
  let current = unify_url(current)
  let flat : Array[SummaryLink] = []
  fn collect(items : Array[SummaryItem]) -> Unit {
    for item in items {
      match item {
        SummaryItem::Link(link) => {
          flat.push(link)
          collect(link.nested_items)
        }
        _ => ()
      }
    }
  }
  collect(self.prefix_chapters)
  collect(self.numbered_chapters)
  collect(self.suffix_chapters)

  fn to_val(link : SummaryLink) -> (String, String) {
    (link.title, link.location)
  }

  match flat.search_by(link => link.location == current) {
    None => (None, None)
    Some(i) =>
      (
        if i > 0 {
          Some(to_val(flat[i - 1]))
        } else {
          None
        },
        if i < flat.length() - 1 {
          Some(to_val(flat[i + 1]))
        } else {
          None
        },
      )
  }
}