// Public library facade.  Everything here is pure: no file system access, so
// the same API compiles for native, wasm-gc and JavaScript.  Hosts that want
// `include` expansion pass a loader callback.

///|
fn lib_view(view : StringView) -> String {
  String::from_iter(view.iter())
}

///|
fn lib_trim(text : String) -> String {
  lib_view(text.trim())
}

///|
fn lib_is_space(c : Char) -> Bool {
  c == ' ' || c == '\t'
}

///|
fn lib_include_target(line : String) -> String? {
  let text = lib_trim(line)
  if text.length() < 8 {
    return None
  }
  let head = lib_view(text[0:7])
  if head.to_lower() != "include" {
    return None
  }
  if !lib_is_space(
      match text.get_char(7) {
        Some(c) => c
        None => ' '
      },
    ) {
    return None
  }
  let mut start = -1
  let mut i = 8
  while i < text.length() {
    let c = match text.get_char(i) {
      Some(c) => c
      None => break
    }
    if c == '"' {
      start = i + 1
      break
    }
    i = i + 1
  }
  if start < 0 {
    return None
  }
  let mut end = start
  while end < text.length() {
    let c = match text.get_char(end) {
      Some(c) => c
      None => break
    }
    if c == '"' {
      return Some(lib_view(text[start:end]))
    }
    end = end + 1
  }
  None
}

///|
/// Returns the path named by an `include "path"` directive, or None when the
/// line is not an include.
pub fn include_target(line : String) -> String? {
  lib_include_target(line)
}

///|
fn lib_is_separator(c : Char) -> Bool {
  c == '/' || c == '\\'
}

///|
/// Return the parent directory using portable slash handling.
pub fn path_dirname(path : String) -> String {
  let mut i = path.length() - 1
  while i >= 0 {
    let c = match path.get_char(i) {
      Some(c) => c
      None => break
    }
    if lib_is_separator(c) {
      if i == 0 {
        return "/"
      }
      return lib_view(path[0:i])
    }
    i = i - 1
  }
  ""
}

///|
fn lib_normalize_path(input : String) -> String {
  let replaced = input.replace_all(old="\\", new="/")
  let drive = replaced.get_char(1) == Some(':')
  let path = if drive { replaced.to_lower() } else { replaced }
  let slash_root = path.has_prefix("/")
  let unc = path.has_prefix("//")
  let absolute = slash_root || drive
  let root_parts = if unc { 2 } else if drive { 1 } else { 0 }
  let parts : Array[String] = []
  for view in path.split("/") {
    let part = lib_view(view)
    if part == "" || part == "." {
      continue
    }
    if part == ".." {
      if parts.length() > root_parts && parts[parts.length() - 1] != ".." {
        parts.pop() |> ignore
      } else if !absolute {
        parts.push(part)
      }
      continue
    }
    parts.push(part)
  }
  let sb = StringBuilder()
  if slash_root {
    sb.write_string(if unc { "//" } else { "/" })
  }
  for i in 0.. 0 {
      sb.write_string("/")
    }
    sb.write_string(parts[i])
  }
  sb.to_string()
}

///|
/// Resolve a relative include path against its containing directory.
pub fn path_join(base : String, relative : String) -> String {
  if relative.get_char(1) == Some(':') {
    return lib_normalize_path(relative)
  }
  if relative.length() > 0 &&
    lib_is_separator(
      match relative.get_char(0) {
        Some(c) => c
        None => '/'
      },
    ) {
    return lib_normalize_path(relative)
  }
  if base == "" {
    lib_normalize_path(relative)
  } else {
    lib_normalize_path(
      base +
      (if base.has_suffix("/") || base.has_suffix("\\") { "" } else { "/" }) +
      relative,
    )
  }
}

///|
/// Expanded text plus deterministic include failures. Repeated non-ancestor includes are allowed.
pub(all) struct ExpandedSource {
  source : String
  source_map : Array[@ast.Loc]
  missing : Array[String]
  errors : Array[@ast.Diagnostic]
}

///|
struct IncludeFrame {
  file : String
  base : String
  lines : Array[String]
  mut index : Int32
}

///|
/// Pure request/response include walker shared by synchronous and asynchronous hosts.
/// Call next(), supply the requested file with provide(), then finish() after next returns None.
pub struct IncludeExpander {
  frames : Array[IncludeFrame]
  output : Array[String]
  source_map : Array[@ast.Loc]
  missing : Array[String]
  errors : Array[@ast.Diagnostic]
  mut pending : (String, String, @ast.Loc)?
}

///|
/// Seeds the recursion stack with the normalized root filename.
pub fn IncludeExpander::new(
  file : String,
  base_dir : String,
  source : String,
) -> IncludeExpander {
  {
    frames: [
      {
        file: if path_dirname(file) == "" {
          path_join(base_dir, file)
        } else {
          path_join("", file)
        },
        base: base_dir,
        lines: source.split("\n").map(lib_view).to_array(),
        index: 0,
      },
    ],
    output: [],
    source_map: [],
    missing: [],
    errors: [],
    pending: None,
  }
}

///|
/// Returns the next path to load. Detects cycles before requesting host I/O.
pub fn IncludeExpander::next(self : IncludeExpander) -> String? {
  if self.pending is Some((path, _, _)) {
    return Some(path)
  }
  while self.frames.length() > 0 {
    let frame = self.frames[self.frames.length() - 1]
    if frame.index >= frame.lines.length() {
      self.frames.pop() |> ignore
      continue
    }
    let line = frame.lines[frame.index]
    frame.index += 1
    match include_target(line) {
      None => {
        self.output.push(line)
        self.source_map.push(@ast.Loc::new(frame.file, frame.index))
      }
      Some(relative) => {
        let path = path_join(frame.base, relative)
        let loc = @ast.Loc::new(frame.file, frame.index)
        if self.frames.any(fn(f) { f.file == path }) {
          self.errors.push(
            @ast.Diagnostic::new(
              "include.cycle",
              "Include cycle: " +
              self.frames.map(fn(f) { f.file }).join(" -> ") +
              " -> " +
              path,
              loc,
            ),
          )
        } else if self.frames.length() >= 1024 {
          self.errors.push(
            @ast.Diagnostic::new(
              "include.depth", "Include nesting exceeds 1024 files", loc,
            ),
          )
        } else {
          self.pending = Some((path, line, loc))
          return Some(path)
        }
      }
    }
  }
  None
}

///|
/// Supplies a requested file; None retains the directive and records include.missing.
pub fn IncludeExpander::provide(
  self : IncludeExpander,
  content : String?,
) -> Unit {
  if self.pending is Some((path, line, loc)) {
    self.pending = None
    match content {
      Some(source) =>
        self.frames.push({
          file: path,
          base: path_dirname(path),
          lines: source.split("\n").map(lib_view).to_array(),
          index: 0,
        })
      None => {
        self.missing.push(path)
        self.output.push(line)
        self.source_map.push(loc)
        self.errors.push(
          @ast.Diagnostic::new(
            "include.missing",
            "Cannot read include: " + path,
            loc,
          ),
        )
      }
    }
  }
}

///|
/// Returns accumulated output and diagnostics after all requests have been served.
pub fn IncludeExpander::finish(self : IncludeExpander) -> ExpandedSource {
  {
    source: self.output.join("\n"),
    source_map: self.source_map,
    missing: self.missing,
    errors: self.errors,
  }
}

///|
/// Expands includes with a synchronous loader. Async hosts use the same walker directly.
pub fn expand_includes(
  source : String,
  base_dir : String,
  read : (String) -> String?,
  file~ : String = "",
) -> ExpandedSource {
  let walker = IncludeExpander::new(file, base_dir, source)
  while walker.next() is Some(path) {
    walker.provide(read(path))
  }
  walker.finish()
}

///|
/// Parse, book and validate text without filesystem access; inspect returned errors before reporting balances.
pub fn check_source(
  file : String,
  source : String,
  source_map~ : Array[@ast.Loc] = [],
) -> @validation.ValidatedLedger {
  @validation.validate_ledger(@syntax.parse_source(file, source, source_map~))
}

///|
/// Expand includes using the host callback, then validate the expanded ledger.
pub fn check_expanded(
  file : String,
  base_dir : String,
  source : String,
  read : (String) -> String?,
) -> @validation.ValidatedLedger {
  let expanded = expand_includes(source, base_dir, read, file~)
  let ledger = check_source(
    file,
    expanded.source,
    source_map=expanded.source_map,
  )
  ledger.errors.append(expanded.errors)
  ledger
}

///|
/// Build account inventories and prices; at includes entries through that date.
pub fn realize(
  ledger : @validation.ValidatedLedger,
  at~ : @ast.Date? = None,
) -> @realization.RealizedLedger {
  @realization.realize(ledger, at~)
}

///|
/// Run the supported BQL subset; inspect QueryResult.error before consuming rows.
pub fn run_query(
  ledger : @validation.ValidatedLedger,
  statement : String,
  at~ : @ast.Date? = None,
) -> @query.QueryResult {
  @query.run_query(realize(ledger, at~), statement)
}

///|
/// Align posting amounts while preserving ledger semantics.
pub fn format_source(source : String) -> String {
  @format.format_source(source)
}

///|
/// Render the stable diagnostic code and original source location.
pub fn diagnostic_text(diagnostic : @ast.Diagnostic) -> String {
  diagnostic.loc.file +
  ":" +
  diagnostic.loc.line.to_string() +
  ": " +
  diagnostic.code +
  ": " +
  diagnostic.message
}