///|
/// The result of normalizing a path: the segments that remain after `.` and
/// `..` are removed, and how many `..` segments climbed above the start.
pub(all) struct Normalized {
  segments : ReadOnlyArray[String]
  above : Int32
} derive(Eq, Debug)

///|
/// Splits `text` on `/` and `\`, removes empty and `.` segments, and applies
/// `..` segments. A `..` with nothing left to remove counts in `above`.
pub fn normalize_path(text : String) -> Normalized {
  let segments : Array[String] = []
  let mut above = 0
  for part in text.replace_all(old="\\", new="/").split("/") {
    let part = part.to_owned()
    match part {
      "" | "." => ()
      ".." =>
        if segments.is_empty() {
          above += 1
        } else {
          segments.pop() |> ignore
        }
      _ => segments.push(part)
    }
  }
  { segments: ReadOnlyArray::from_array(segments), above, }
}

///|
/// Raised for a path that cannot name a file inside a root.
pub(all) suberror PathError {
  /// The path climbs above its root, for example `../outside.md`.
  EscapesRoot(String)
  /// The path names no file, for example `""` or `/`.
  EmptyPath(String)
} derive(Eq, Debug)

///|
/// A file path relative to the root of a document set or store. It never
/// escapes that root. Its text form uses `/` and has no leading `/`.
struct StorePath {
  segments : ReadOnlyArray[String]
} derive(Eq, Hash, Debug)

///|
/// Validates `text` as a store path. `/` and `\` are both separators.
pub fn StorePath::StorePath(text : String) -> StorePath raise PathError {
  let normalized = normalize_path(text)
  if normalized.above > 0 {
    raise EscapesRoot(text)
  }
  if normalized.segments.length() == 0 {
    raise EmptyPath(text)
  }
  { segments: normalized.segments, }
}

///|
fn StorePath::of_segments(segments : Array[String]) -> StorePath {
  { segments: ReadOnlyArray::from_array(segments), }
}

///|
pub fn StorePath::segments(self : StorePath) -> ReadOnlyArray[String] {
  self.segments
}

///|
/// The last segment, for example `customers.md`.
pub fn StorePath::name(self : StorePath) -> String {
  self.segments[self.segments.length() - 1]
}

///|
/// The directory of the file: the root for a file at the root.
pub fn StorePath::parent(self : StorePath) -> StoreDir {
  StoreDir::of_segments(
    self.segments.exact_view(end=self.segments.length() - 1).to_owned(),
  )
}

///|
/// True when `self` is inside the directory `dir`, at any depth.
pub fn StorePath::is_under(self : StorePath, dir : StoreDir) -> Bool {
  let dir = dir.segments()
  if dir.length() >= self.segments.length() {
    return false
  }
  for i, segment in dir {
    if self.segments[i] != segment {
      return false
    }
  }
  true
}

///|
pub impl Show for StorePath with fn output(self, logger) {
  logger.write_string(self.segments.join("/"))
}

///|
pub fn StorePath::to_string(self : StorePath) -> String {
  self.segments.join("/")
}

///|
/// A path relative to a bundle root. Its text form starts with `/`, as OKF
/// bundle-rooted links do, for example `/tables/customers.md`.
struct BundlePath {
  segments : ReadOnlyArray[String]
} derive(Eq, Hash, Debug)

///|
/// Validates `text` as a bundle path. A leading `/` is optional.
pub fn BundlePath::BundlePath(text : String) -> BundlePath raise PathError {
  let normalized = normalize_path(text)
  if normalized.above > 0 {
    raise EscapesRoot(text)
  }
  if normalized.segments.length() == 0 {
    raise EmptyPath(text)
  }
  { segments: normalized.segments, }
}

///|
fn BundlePath::of_segments(segments : Array[String]) -> BundlePath {
  { segments: ReadOnlyArray::from_array(segments), }
}

///|
/// The root index, `/index.md`.
pub fn BundlePath::root_index() -> BundlePath {
  BundlePath::of_segments(["index.md"])
}

///|
/// The root log, `/log.md`.
pub fn BundlePath::root_log() -> BundlePath {
  BundlePath::of_segments(["log.md"])
}

///|
pub fn BundlePath::segments(self : BundlePath) -> ReadOnlyArray[String] {
  self.segments
}

///|
pub fn BundlePath::name(self : BundlePath) -> String {
  self.segments[self.segments.length() - 1]
}

///|
/// The directory segments, without the file name.
pub fn BundlePath::directory(self : BundlePath) -> Array[String] {
  self.segments.exact_view(end=self.segments.length() - 1).to_owned()
}

///|
/// The number of directories above the file, 0 for a file at the bundle root.
pub fn BundlePath::depth(self : BundlePath) -> Int32 {
  self.segments.length() - 1
}

///|
/// True when the path ends with `suffix`, compared segment by segment.
pub fn BundlePath::ends_with(self : BundlePath, suffix : String) -> Bool {
  let wanted = normalize_path(suffix).segments
  let n = wanted.length()
  let m = self.segments.length()
  if n == 0 || n > m {
    return false
  }
  for i in 0.. String {
  "/" + self.segments.join("/")
}

///|
/// The OKF Concept ID: the bundle path without its leading `/` and without the
/// `.md` extension, for example `tables/customers`.
struct ConceptId {
  text : String
} derive(Eq, Hash, Debug)

///|
/// Validates `text` as a Concept ID. A trailing `.md` and a leading `/` are
/// removed, so `/tables/customers.md` and `tables/customers` are equal. A path
/// whose file name is only `.md`, such as `/.md`, raises `EmptyPath`.
pub fn ConceptId::ConceptId(text : String) -> ConceptId raise PathError {
  match ConceptId::of_path(BundlePath(text)) {
    Some(id) => id
    None => raise EmptyPath(text)
  }
}

///|
/// The Concept ID of the file at `path`, or `None` when the file name is only
/// `.md`, which leaves an empty name.
pub fn ConceptId::of_path(path : BundlePath) -> ConceptId? {
  let name = path.name()
  let stem = if name.has_suffix(".md") {
    name[:name.length() - 3].to_owned()
  } else {
    name
  }
  guard stem.length() > 0 else { return None }
  let segments = path.segments.iter().collect()
  segments[segments.length() - 1] = stem
  Some({ text: segments.join("/"), })
}

///|
/// The bundle path of the Markdown file for this Concept ID.
pub fn ConceptId::to_path(self : ConceptId) -> BundlePath {
  BundlePath::of_segments(
    (self.text + ".md").split("/").map(s => s.to_owned()).collect(),
  )
}

///|
pub impl Show for ConceptId with fn output(self, logger) {
  logger.write_string(self.text)
}

///|
pub fn ConceptId::to_string(self : ConceptId) -> String {
  self.text
}

///|
/// True when `name` ends with `.md`, ignoring letter case.
pub fn is_markdown_name(name : String) -> Bool {
  name.to_lower().has_suffix(".md")
}

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

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

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

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

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

///|

///|
pub extend StorePath with Hash::{hash, hash_combine}

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

///|
pub extend StorePath with Show::{output}

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

///|

///|
pub extend BundlePath with Hash::{hash, hash_combine}

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

///|
pub extend BundlePath with Show::{output}

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

///|

///|
pub extend ConceptId with Hash::{hash, hash_combine}

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

///|
pub extend ConceptId with Show::{output}

///|
/// Compares two strings code unit by code unit, so that `a` < `ab` < `b`.
pub fn lexical_compare(a : String, b : String) -> Int32 {
  let n = if a.length() < b.length() { a.length() } else { b.length() }
  for i in 0.. Int32 {
  let n = if a.length() < b.length() { a.length() } else { b.length() }
  for i in 0..