///|
/// An immutable MDC document. Cmark locations are relative to `body()`;
/// `source_range` translates them to the original input. Offsets count UTF-16
/// code units.
struct Document {
  frontmatter : Frontmatter?
  markdown : @cmark.Doc
  body : String
  body_offset : Int32
  body_line : Int32
} derive(Debug)

///|
pub extend Document with @debug.Debug::{to_repr}

///|
pub fn Document::frontmatter(self : Document) -> Frontmatter? {
  self.frontmatter
}

///|
pub fn Document::body(self : Document) -> String {
  self.body
}

///|
pub fn Document::body_offset(self : Document) -> Int32 {
  self.body_offset
}

///|
pub fn Document::body_line(self : Document) -> Int32 {
  self.body_line
}

///|
/// The range in the original input of a cmark location in the body, or `None`
/// for a location that cmark did not record or that is not a location in this
/// document's body.
pub fn Document::source_range(
  self : Document,
  loc : @cmark_base.TextLoc,
) -> SourceRange? {
  guard !loc.is_none() else { return None }
  let position = (line : @cmark_base.LinePos, ccode : Int32) => {
    // A first body line that is also the first source line starts after the
    // byte order mark, when there is one.
    let shift = if self.body_line == 1 && line.0 == 1 {
      self.body_offset
    } else {
      0
    }
    TextPosition(
      line=self.body_line + line.0 - 1,
      column=ccode - line.1 + 1 + shift,
    )
  }
  let start_offset = self.body_offset + loc.first_ccode
  let end_offset = if loc.is_empty() {
    start_offset
  } else {
    self.body_offset + loc.last_ccode + 1
  }
  guard end_offset <= self.body_offset + self.body.length() else { return None }
  try {
    let start = position(loc.first_line, loc.first_ccode)
    let end = if loc.is_empty() {
      start
    } else {
      position(loc.last_line, loc.last_ccode)
    }
    SourceRange(start~, end~, start_offset~, end_offset~)
  } catch {
    _ => None
  } noraise {
    range => Some(range)
  }
}

///|
/// An owned cmark copy for rendering or transformation. Its mutable maps and
/// sequences share no storage with the document or its traversal views.
pub fn Document::to_cmark(self : Document) -> @cmark.Doc {
  copy_doc(self.markdown)
}

///|
pub fn Document::root(self : Document) -> Node {
  Node::Document(self)
}

///|
pub(all) suberror ParseError {
  /// The frontmatter has no closing delimiter line.
  UnterminatedFrontmatter
  /// The frontmatter payload is not one YAML mapping. An `InvalidYaml`
  /// position is a position in the document.
  InvalidFrontmatter(FrontmatterError)
} derive(Debug)

///|
pub extend ParseError with @debug.Debug::{to_repr}

///|
// Return the line text and the offset after its newline, accepting LF, CRLF
// and CR. Delimiters may have trailing horizontal whitespace.
fn line_at(source : String, start : Int32) -> (String, Int32) {
  let mut end = start
  while end < source.length() {
    let c = source.code_unit_at(end)
    if c == 10 || c == 13 {
      break
    }
    end += 1
  }
  let text = source[start:end].to_owned()
  let mut next = end
  if next < source.length() {
    next += 1
    if source.code_unit_at(end) == 13 &&
      next < source.length() &&
      source.code_unit_at(next) == 10 {
      next += 1
    }
  }
  (text, next)
}

///|
/// Parse CommonMark with an optional leading YAML mapping between `---` lines.
/// `...` also closes frontmatter. A UTF-8 BOM decoded as U+FEFF is accepted.
/// An opener must be the first line, with no leading whitespace. An empty
/// payload is an empty mapping. Malformed YAML and non-mappings are errors.
/// Cmark's `strict` option defaults to true. Set it to false for extensions.
pub fn Document::Document(
  source : String,
  strict~ : Bool = true,
  layout~ : Bool = true,
  locs~ : Bool = true,
  file~ : String = "",
) -> Document raise ParseError {
  let start = if source.has_prefix("\u{FEFF}") { 1 } else { 0 }
  let (first, after_first) = line_at(source, start)
  let mut body_offset = start
  let mut body_line = 1
  let frontmatter = if first.trim_end(chars=" \t") == "---" {
    let mut offset = after_first
    let mut line = 2
    let mut end = None
    while offset < source.length() {
      let (text, next) = line_at(source, offset)
      let delimiter = text.trim_end(chars=" \t")
      if delimiter == "---" || delimiter == "..." {
        end = Some(offset)
        body_offset = next
        body_line = if next > offset + text.length() { line + 1 } else { line }
        break
      }
      offset = next
      line += 1
    }
    guard end is Some(end) else { raise UnterminatedFrontmatter }
    let frontmatter = Frontmatter::Frontmatter(
      source[after_first:end].to_owned(),
    ) catch {
      // The payload starts on the second line of the document.
      InvalidYaml(at~, message~) =>
        raise InvalidFrontmatter(
          InvalidYaml(at={ ..at, line: at.line + 1, }, message~),
        )
      error => raise InvalidFrontmatter(error)
    }
    Some(frontmatter)
  } else {
    None
  }
  let body = source[body_offset:].to_owned()
  let markdown = @cmark.Doc::from_string(body, strict~, layout~, locs~, file~)
  { frontmatter, markdown, body, body_offset, body_line, }
}

///|
/// Parse an immutable document. Equivalent to `Document::Document`.
pub fn parse(
  source : String,
  strict~ : Bool = true,
  layout~ : Bool = true,
  locs~ : Bool = true,
  file~ : String = "",
) -> Document raise ParseError {
  Document::Document(source, strict~, layout~, locs~, file~)
}