///|
pub suberror ResolveError {
  InvalidReference(String)
} derive(Debug)

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

///|
priv struct ResolveState {
  options : ParseOptions
  max_files : Int
  mut files : Int
  mut chars : Int
  mut entries : Int
  references : Array[ReferenceEdge]
}

///|
/// Load and resolve root-level includes and bases through an explicit loader.
/// Virtual paths use / separators. Budgets count every loaded file, including
/// repeated references, and entries parsed from filtered conditional branches.
pub fn load_resolved(
  source : String,
  loader : (String) -> String raise,
  options? : ParseOptions = ParseOptions::default(),
  max_files? : Int = 64,
) -> Document raise {
  load_project(source, loader, options~, max_files~).document
}

///|
fn normalize_reference(path : String) -> String raise ResolveError {
  let text = path.replace_all(old="\\", new="/")
  let rooted = text.has_prefix("/")
  let parts : Array[String] = []
  for part in text.split("/") {
    if part == "" || part == "." {
      continue
    }
    if part == ".." {
      if parts.is_empty() || (parts.length() == 1 && parts[0].has_suffix(":")) {
        raise InvalidReference("Reference escapes its virtual root")
      }
      parts.pop() |> ignore
    } else {
      parts.push(part.to_owned())
    }
  }
  if parts.is_empty() {
    raise InvalidReference("Empty file reference")
  }
  (if rooted { "/" } else { "" }) + parts.join("/")
}

///|
fn child_reference(
  parent : String,
  child : String,
) -> String raise ResolveError {
  if child.is_empty() ||
    child.has_prefix("/") ||
    child.has_prefix("\\") ||
    child.contains(":") {
    raise InvalidReference("Include/base paths must be relative")
  }
  let directory = match parent.rev_find("/") {
    Some(index) => parent[:index + 1].to_owned()
    None => ""
  }
  normalize_reference(directory + child)
}

///|
fn resolve_file(
  source : String,
  loader : (String) -> String raise,
  stack : Array[String],
  state : ResolveState,
) -> Array[ResolvedNode] raise {
  if stack.contains(source) {
    raise InvalidReference("Cyclic reference: " + source)
  }
  if state.files >= state.max_files {
    raise InvalidReference("File count exceeds max_files")
  }
  state.files += 1
  let text = loader(source) catch {
    error =>
      raise InvalidReference("Cannot load " + source + ": " + error.to_string())
  }
  let chars = text.char_length()
  if chars > state.options.max_input_chars - state.chars {
    raise InvalidReference("Referenced input exceeds global character budget")
  }
  state.chars += chars
  let traces : Array[TraceNode] = []
  let (doc, count, _) = parse_with_spans(
    text,
    state.options,
    true,
    false,
    trace=Some(traces),
  )
  if count > state.options.max_entries - state.entries {
    raise InvalidReference("Referenced input exceeds global entry budget")
  }
  state.entries += count
  let next = stack.map(s => s)
  next.push(source)
  let result : Array[ResolvedNode] = []
  let includes : Array[ResolvedNode] = []
  let bases : Array[ResolvedNode] = []
  for node in traced_nodes(doc.entries, traces, source) {
    let entry = node.entry
    let is_include = entry.key.equal_ignore_ascii_case("#include")
    let base = entry.key.equal_ignore_ascii_case("#base")
    if is_include || base {
      guard entry.value is Text(_) else {
        raise InvalidReference("Directive value must be a file path")
      }
      if is_include {
        includes.push(node)
      } else {
        bases.push(node)
      }
    } else {
      result.push(node)
    }
  }
  for reference in includes {
    let path = reference.entry.value.as_text().unwrap()
    let target = child_reference(source, path)
    state.references.push({
      source,
      target,
      kind: "include",
      location: reference.location,
    })
    let included = resolve_file(target, loader, next, state)
    for entry in included {
      result.push(entry)
    }
  }
  let mut merged = result
  for reference in bases {
    let path = reference.entry.value.as_text().unwrap()
    let target = child_reference(source, path)
    state.references.push({
      source,
      target,
      kind: "base",
      location: reference.location,
    })
    let base = resolve_file(target, loader, next, state)
    merged = merge_base(merged, base)
  }
  merged
}

///|
/// Pair duplicate keys by occurrence when merging; existing scalar values win.
fn merge_base(
  current : Array[ResolvedNode],
  base : Array[ResolvedNode],
) -> Array[ResolvedNode] {
  let result = current.map(e => e)
  let positions : Map[String, Array[Int]] = Map([])
  let occurrences : Map[String, Int] = Map([])
  for i in 0.. indices.push(i)
      None => positions[key] = [i]
    }
  }
  for entry in base {
    let key = reference_key(entry.entry.key)
    let occurrence = occurrences.get(key).unwrap_or(0)
    occurrences[key] = occurrence + 1
    let found = match positions.get(key) {
      Some(indices) => indices.get(occurrence)
      None => None
    }
    match found {
      None => {
        match positions.get(key) {
          Some(indices) => indices.push(result.length())
          None => positions[key] = [result.length()]
        }
        result.push(entry)
      }
      Some(i) =>
        match (result[i].entry.value, entry.entry.value) {
          (Object(_), Object(_)) => {
            let children = merge_base(result[i].children, entry.children)
            result[i] = {
              ..result[i],
              entry: {
                key: result[i].entry.key,
                value: Object(children.map(n => n.entry)),
              },
              children,
            }
          }
          _ => ()
        }
    }
  }
  result
}

///|
fn reference_key(key : String) -> String {
  let builder = StringBuilder()
  for c in key {
    builder.write_char(
      if c >= 'A' && c <= 'Z' {
        (c.to_int() + 32).to_char().unwrap()
      } else {
        c
      },
    )
  }
  builder.to_string()
}