///|
/// Map a resource path prefix to an explicit directory, never an inferred game root.
pub(all) struct ResourceRoot {
  prefix : String
  directory : String
} derive(Eq, Debug)

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

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

///|
pub(all) struct ResourceReference {
  path : Array[String]
  resource : String
  target : String?
  status : String
  start : Int32
  end : Int32
} derive(Eq, Debug)

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

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

///|
pub fn resource_roots_from_json(json : Json) -> Array[ResourceRoot] raise {
  guard json is Array(rows) && rows.length() <= 32 else {
    raise InvalidReference("Expected at most 32 resource roots")
  }
  let roots = rows.map(row => {
    guard row is Object(fields) &&
      fields.length() == 2 &&
      fields.get("prefix") is Some(String(prefix)) &&
      fields.get("directory") is Some(String(directory)) else {
      raise InvalidReference("Resource root requires prefix and directory")
    }
    ResourceRoot::{ prefix, directory, }
  })
  resource_roots(roots)
  roots
}

///|
fn resource_relative(path : String) -> Bool {
  !path.is_empty() &&
  path.length() <= 4096 &&
  !path.has_prefix("/") &&
  !path.contains("\\") &&
  !path.contains(":") &&
  !path.to_array().any(c => c.to_int() < 32 || c.to_int() == 127) &&
  path.split("/").all(p => p != "" && p != "." && p != "..")
}

///|
fn resource_roots(roots : Array[ResourceRoot]) -> Unit raise {
  if roots.length() > 32 {
    raise InvalidReference("At most 32 resource roots")
  }
  let prefixes : Map[String, Bool] = Map([])
  for root in roots {
    if prefixes.contains(root.prefix) ||
      (
        !root.prefix.is_empty() &&
        (
          !root.prefix.has_suffix("/") ||
          !resource_relative(root.prefix[:root.prefix.length() - 1].to_owned())
        )
      ) ||
      root.directory.is_empty() ||
      root.directory.length() > 32768 ||
      root.directory.contains("\\") ||
      root.directory.to_array().any(c => c.to_int() < 32) ||
      (
        root.directory != "." &&
        normalize_reference(root.directory) != root.directory
      ) {
      raise InvalidReference(
        "Resource roots require unique relative prefixes and normalized directories",
      )
    }
    prefixes[root.prefix] = true
  }
}

///|
fn resource_target(path : String, roots : Array[ResourceRoot]) -> String? raise {
  let mut best : ResourceRoot? = None
  for root in roots {
    if path.has_prefix(root.prefix) &&
      (best is None || root.prefix.length() > best.unwrap().prefix.length()) {
      best = Some(root)
    }
  }
  best.map(root => {
    normalize_reference(
      root.directory + "/" + path[root.prefix.length():].to_owned(),
    )
  })
}

///|
/// Only resource:"path" is a file reference. resource_name, soundevent and
/// localization/entity names are deliberately not guessed to be filesystem paths.
pub fn KV3SourceDocument::resources(
  self : KV3SourceDocument,
  roots~ : Array[ResourceRoot] = [],
) -> Array[ResourceReference] raise {
  resource_roots(roots)
  let result : Array[ResourceReference] = []
  fn walk(
    value : KV3Value,
    indices : Array[Int32],
    path : Array[String],
  ) -> Unit raise {
    if value is KV3Flag(flag, KV3String(resource)) && flag == "resource" {
      if result.length() >= 10000 {
        raise InvalidReference("Resource query exceeds 10000 references")
      }
      let valid = resource_relative(resource)
      let target = if valid { resource_target(resource, roots) } else { None }
      let span = self.spans[index_key(indices)]
      result.push({
        path,
        resource,
        target,
        start: span.start,
        end: span.end,
        status: if resource.is_empty() {
          "empty"
        } else if !valid {
          "invalid"
        } else if roots.is_empty() {
          "unconfigured"
        } else if target is None {
          "unmapped"
        } else {
          "resolved"
        },
      })
    }
    match kv3_unflag(value) {
      KV3Object(entries) =>
        for i in 0..
        for i in 0.. ()
    }
  }
  walk(self.doc.root, [], [])
  result
}

///|
pub fn resource_references_to_json(
  references : Array[ResourceReference],
) -> Json {
  Json::array(
    references.map(r => {
      Json::object({
        "path": Json::array(r.path.map(s => Json::string(s))),
        "resource": Json::string(r.resource),
        "target": match r.target {
          Some(s) => Json::string(s)
          None => Json::null()
        },
        "status": Json::string(r.status),
        "start": Json::number(r.start.to_double()),
        "end": Json::number(r.end.to_double()),
      })
    }),
  )
}

///|
fn resource_inverse(
  target : String,
  roots : Array[ResourceRoot],
) -> String raise {
  let mut best : ResourceRoot? = None
  for root in roots {
    let directory = if root.directory == "." {
      ""
    } else if root.directory == "/" {
      "/"
    } else {
      root.directory + "/"
    }
    if target.has_prefix(directory) &&
      (best is None || directory.length() > best.unwrap().directory.length()) {
      best = Some(root)
    }
  }
  guard best is Some(root) else {
    raise InvalidReference("Moved resource leaves configured directories")
  }
  let directory = if root.directory == "." {
    ""
  } else if root.directory == "/" {
    "/"
  } else {
    root.directory + "/"
  }
  let result = root.prefix + target[directory.length():].to_owned()
  if !resource_relative(result) ||
    resource_target(result, roots) != Some(target) {
    raise InvalidReference("Destination resource mapping is ambiguous")
  }
  result
}

///|
/// Return a source-preserving reference preview for file or directory moves.
/// This does not move asset bytes. Only original targets are matched, once.
pub fn KV3SourceDocument::rewrite_resources(
  self : KV3SourceDocument,
  roots : Array[ResourceRoot],
  moves : Map[String, String],
) -> KV3SourceDocument raise {
  resource_roots(roots)
  if moves.length() > 64 {
    raise InvalidReference("At most 64 resource moves")
  }
  let pairs = moves.iter().collect()
  for i in 0..