///|
/// The kind of a link destination.
pub(all) enum LinkKind {
  /// A URI with a scheme, such as `https:` or `mailto:`.
  External
  /// A fragment in the same document, such as `#schema`.
  Anchor
  /// A path from the bundle root, such as `/tables/customers.md`.
  BundleRooted
  /// A path relative to a base directory.
  Relative
} derive(Eq, Debug)

///|
/// Where a link comes from. Only this package makes body links, from lines
/// that it reads, so a body line is always a real file line.
pub enum LinkOrigin {
  /// A link in a document body, with its file line, counted from 1.
  Body(line~ : Int32)
  /// A link that a profile adds.
  FromProfile
} derive(Eq, Debug)

///|
/// One link. Its kind always follows from its destination, so only this
/// package and `Link::Link` make links. `Link::Link` makes profile links.
pub struct Link {
  text : String
  destination : String
  origin : LinkOrigin
  kind : LinkKind
} derive(Eq, Debug)

///|
/// The file line of a body link.
pub fn Link::line_number(self : Link) -> Int32? {
  match self.origin {
    Body(line~) => Some(line)
    FromProfile => None
  }
}

///|
fn has_scheme(dest : String) -> Bool {
  let n = dest.length()
  if n == 0 {
    return false
  }
  let first = dest[0]
  guard (first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') else {
    return false
  }
  for i in 1.. 1
    }
    let ok = (c >= 'a' && c <= 'z') ||
      (c >= 'A' && c <= 'Z') ||
      (c >= '0' && c <= '9') ||
      c == '+' ||
      c == '-' ||
      c == '.'
    if !ok {
      return false
    }
  }
  false
}

///|
/// Classifies a link destination.
pub fn classify_destination(dest : String) -> LinkKind {
  if dest.has_prefix("#") {
    Anchor
  } else if dest.has_prefix("/") {
    BundleRooted
  } else if has_scheme(dest) {
    External
  } else {
    Relative
  }
}

///|
/// A link that a profile adds.
pub fn Link::Link(text : String, destination : String) -> Link {
  {
    text,
    destination,
    origin: FromProfile,
    kind: classify_destination(destination),
  }
}

///|
/// A body link at the file line `line`, which this package read.
fn body_link(text : String, destination : String, line : Int32) -> Link {
  {
    text,
    destination,
    origin: Body(line~),
    kind: classify_destination(destination),
  }
}

///|
/// The destination without its fragment.
pub fn Link::path_part(self : Link) -> String {
  match self.destination.find("#") {
    Some(i) => self.destination[:i].to_owned()
    None => self.destination
  }
}

///|
/// The fragment after `#`, if any.
pub fn Link::fragment(self : Link) -> String? {
  match self.destination.find("#") {
    Some(i) => Some(self.destination[i + 1:].to_owned())
    None => None
  }
}

///|
fn plain_text(inline : @cmark.Inline) -> String {
  inline
  .to_plain_text(break_on_soft=false)
  .fold(init="", (acc, line) => acc + line.fold(init="", (a, s) => a + s))
}

///|
/// The destination of a cmark link, resolving reference links through `defs`.
/// A footnote reference gives `None`.
fn link_destination(
  link : @cmark.InlineLink,
  defs : Map[String, @cmark.LabelDef],
) -> String? {
  match link.reference {
    Inline(def) =>
      match def.v.dest {
        Some(dest) => Some(dest.v)
        None => Some("")
      }
    Ref(_, _, _) =>
      match link.reference_definition(defs) {
        Some(LinkDef(def)) =>
          match def.v.dest {
            Some(dest) => Some(dest.v)
            None => Some("")
          }
        _ => None
      }
  }
}

///|
/// The footnote label of a cmark link, when it is a footnote reference.
fn footnote_label(
  link : @cmark.InlineLink,
  defs : Map[String, @cmark.LabelDef],
) -> String? {
  match link.reference {
    Ref(_, label, _) =>
      match link.reference_definition(defs) {
        Some(FootnoteDef(_)) => {
          let key = label.key
          Some(if key.has_prefix("^") { key[1:].to_owned() } else { key })
        }
        _ => None
      }
    _ => None
  }
}

///|
/// The file line of a cmark location in the parsed body. The body is parsed
/// with locations, so every node has one; a node without one gets the first
/// body line.
fn Document::line_of(self : Document, loc : @cmark_base.TextLoc) -> Int32 {
  match self.markdown.bind(md => md.source_range(loc)) {
    Some(range) => self.file_line(range.start().line())
    None => self.header.body_line
  }
}

///|
/// The body links of a document: inline, reference and autolinks, with file
/// lines. Images, footnote references and text inside code are left out.
pub fn Document::links(self : Document) -> Array[Link] {
  guard self.markdown is Some(md) else { return [] }
  let defs = md.to_cmark().defs
  let links = []
  @mdc.walk(md.root(), node => {
    match node {
      Inline(view) =>
        match view.kind() {
          Link =>
            match view.to_cmark() {
              Link(node) =>
                match link_destination(node.v, defs) {
                  Some(dest) =>
                    links.push({
                      text: plain_text(node.v.text),
                      destination: dest,
                      origin: Body(line=self.line_of(view.location())),
                      kind: classify_destination(dest),
                    })
                  None => ()
                }
              _ => ()
            }
          Autolink =>
            match view.to_cmark() {
              Autolink(node) => {
                let dest = if node.v.is_email {
                  "mailto:" + node.v.link.v
                } else {
                  node.v.link.v
                }
                links.push({
                  text: node.v.link.v,
                  destination: dest,
                  origin: Body(line=self.line_of(view.location())),
                  kind: classify_destination(dest),
                })
              }
              _ => ()
            }
          Image => return SkipChildren
          _ => ()
        }
      _ => ()
    }
    Continue
  })
  links
}

///|
/// A footnote reference in a body, such as `[^ga4-schema]`.
pub(all) struct Citation {
  label : String
  line : Int32
} derive(Eq, Debug)

///|
/// The footnote references of a body, in order.
pub fn Document::citations(self : Document) -> Array[Citation] {
  guard self.markdown is Some(md) else { return [] }
  let defs = md.to_cmark().defs
  let out = []
  @mdc.walk(md.root(), node => {
    match node {
      Inline(view) if view.kind() is Link =>
        match view.to_cmark() {
          Link(node) =>
            match footnote_label(node.v, defs) {
              Some(label) =>
                out.push({ label, line: self.line_of(view.location()), })
              None => ()
            }
          _ => ()
        }
      _ => ()
    }
    Continue
  })
  out
}

///|
/// A citation with the `sources` entry whose `id` equals its label.
pub fn Document::cited_sources(self : Document) -> Array[(Citation, Source?)] {
  let sources = self.metadata().sources()
  self
  .citations()
  .map(c => (c, sources.iter().find_first(s => s.id == Some(c.label))))
}

///|
/// Where a link points, before existence checks.
pub(all) enum Resolution {
  /// A URI with a scheme.
  ExternalUri(String)
  /// A fragment of the same document.
  SameDocument(String)
  /// A file path in the store. `escapes` is true when it leaves the bundle.
  StoreTarget(path~ : StorePath, fragment~ : String?, escapes~ : Bool)
  /// The destination is empty or leaves the store root.
  Unresolvable(String)
} derive(Eq, Debug)

///|
/// Resolves a link of `doc` in `bundle` with the link base of `options`.
pub fn resolve_link(
  bundle : Bundle,
  doc : Document,
  link : Link,
  options~ : Options = bundle.options(),
) -> Resolution {
  match link.kind {
    External => ExternalUri(link.destination)
    Anchor => SameDocument(link.fragment().unwrap_or(""))
    BundleRooted | Relative => {
      let path = link.path_part()
      if path.length() == 0 {
        return Unresolvable(link.destination)
      }
      let base : Array[String] = if link.kind is BundleRooted ||
        options.link_base is BundleRoot {
        []
      } else {
        doc.path().directory()
      }
      resolve_path(bundle, base, path, link.fragment(), link.destination)
    }
  }
}

///|
/// Resolves `path` against `base` (bundle segments) inside `bundle`.
fn resolve_path(
  bundle : Bundle,
  base : Array[String],
  path : String,
  fragment : String?,
  original : String,
) -> Resolution {
  let in_bundle = base.copy()
  let mut above = 0
  for part in path.replace_all(old="\\", new="/").split("/") {
    match part.to_owned() {
      "" | "." => ()
      ".." =>
        if in_bundle.is_empty() {
          above += 1
        } else {
          in_bundle.pop() |> ignore
        }
      p => if above > 0 { in_bundle.push(p) } else { in_bundle.push(p) }
    }
  }
  if above == 0 {
    if in_bundle.is_empty() {
      return Unresolvable(original)
    }
    return StoreTarget(
      path=bundle.store_path_of(BundlePath::of_segments(in_bundle)),
      fragment~,
      escapes=false,
    )
  }
  // The path climbs above the bundle root: resolve it from the store root.
  let root = bundle.root().segments().iter().collect()
  let joined = root.copy()
  for p in base {
    joined.push(p)
  }
  let raw = joined.join("/") + "/" + path
  let normalized = normalize_path(raw)
  if normalized.above > 0 || normalized.segments.is_empty() {
    return Unresolvable(original)
  }
  StoreTarget(
    path=StorePath::of_segments(normalized.segments.iter().collect()),
    fragment~,
    escapes=true,
  )
}

///|
/// Resolves a path-valued field (such as `sources[].resource`) of `doc`.
pub fn resolve_field_path(
  bundle : Bundle,
  doc : Document,
  value : String,
  options~ : Options = bundle.options(),
) -> Resolution {
  resolve_link(bundle, doc, Link("", value), options~)
}

///|
/// Tests whether a store path exists. The default resolver knows the loaded
/// documents and assets; a caller can supply one that knows other files.
pub(open) trait LinkResolver {
  fn exists(Self, StorePath) -> Bool
}

///|
/// A resolver that knows every file of a document set.
pub(all) struct DocumentSetResolver {
  set : DocumentSet
}

///|
pub impl LinkResolver for DocumentSetResolver with fn exists(self, path) {
  self.set.contains(path)
}

///|
/// A resolver that knows a fixed list of extra paths. It keeps its own copy of
/// the list.
struct PathListResolver {
  paths : ReadOnlyArray[StorePath]
}

///|
pub fn PathListResolver::PathListResolver(
  paths : ArrayView[StorePath],
) -> PathListResolver {
  { paths: ReadOnlyArray::from_array(paths.to_owned()), }
}

///|
pub impl LinkResolver for PathListResolver with fn exists(self, path) {
  self.paths.contains(path)
}

///|
/// A resolver that knows the documents and assets of loaded knowledge.
pub(all) struct KnowledgeResolver {
  knowledge : Knowledge
}

///|
pub impl LinkResolver for KnowledgeResolver with fn exists(self, path) {
  match self.knowledge.bundle_at(path) {
    Some(bundle) =>
      match bundle.bundle_path_of(path) {
        Some(rel) =>
          bundle.document(rel) is Some(_) || bundle.assets().contains(rel)
        None => false
      }
    None => false
  }
}

///|
pub extend LinkKind with Eq::{not_equal, equal}

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

///|
pub extend Link with Eq::{not_equal, equal}

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

///|
pub extend Citation with Eq::{not_equal, equal}

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

///|
pub extend Resolution with Eq::{not_equal, equal}

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

///|
pub extend DocumentSetResolver with LinkResolver::{exists}

///|
pub extend PathListResolver with LinkResolver::{exists}

///|
pub extend KnowledgeResolver with LinkResolver::{exists}

///|
/// True when a bundle-rooted or relative link climbs above the bundle root,
/// whether or not its target exists.
pub fn link_escapes(
  bundle : Bundle,
  doc : Document,
  link : Link,
  options~ : Options = bundle.options(),
) -> Bool {
  match link.kind {
    External | Anchor => false
    BundleRooted | Relative => {
      let base = if link.kind is BundleRooted || options.link_base is BundleRoot {
        ""
      } else {
        doc.path().directory().join("/")
      }
      normalize_path(base + "/" + link.path_part()).above > 0
    }
  }
}

///|
pub extend LinkOrigin with Eq::{not_equal, equal}

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