///|
pub(all) enum GameMount {
  LooseMount(String, Bool, (String) -> Bool raise, (String) -> Bytes raise)
  VPKMount(String, Bool, VPKArchive, (Int32, Int32, Int32) -> Bytes raise)
}

///|
pub(all) struct MountedFile {
  path : String
  mount : String
  base_game : Bool
  bytes : Bytes
} derive(Eq, Debug)

///|
pub(all) struct MountCandidate {
  mount : String
  base_game : Bool
  selected : Bool
} derive(Eq, Debug)

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

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

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

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

///|
struct GameFileSystem {
  mounts : Array[GameMount]
  max_bytes : Int32
}

///|
fn GameMount::identity(self : GameMount) -> (String, Bool) {
  match self {
    LooseMount(id, base, _, _) | VPKMount(id, base, _, _) => (id, base)
  }
}

///|
/// Mount order is search order: the first existing file wins. Host callbacks
/// provide explicit filesystem access; no directories are discovered implicitly.
pub fn mount_game_filesystem(
  mounts : Array[GameMount],
  max_bytes~ : Int32 = 16777216,
) -> GameFileSystem raise {
  if mounts.length() > 128 || max_bytes < 1 || max_bytes > 67108864 {
    raise InvalidReference("Invalid game filesystem budgets")
  }
  let names : Map[String, Bool] = Map([])
  for mount in mounts {
    let (name, _) = mount.identity()
    if name.is_empty() || names.contains(name) {
      raise InvalidReference("Mount identifiers must be unique and nonempty")
    }
    names[name] = true
  }
  { mounts: mounts.copy(), max_bytes, }
}

///|
fn game_virtual_path(path : String) -> String raise {
  let normalized = path.replace_all(old="\\", new="/")
  if !resource_relative(normalized) {
    raise InvalidReference("Expected a safe game-relative path")
  }
  normalized
}

///|
pub fn GameFileSystem::locate(
  self : GameFileSystem,
  path : String,
) -> Array[MountCandidate] raise {
  let path = game_virtual_path(path)
  let output : Array[MountCandidate] = []
  for mount in self.mounts {
    let exists = match mount {
      LooseMount(_, _, exists, _) => exists(path)
      VPKMount(_, _, archive, _) => archive.find(path) is Some(_)
    }
    if exists {
      let (id, base) = mount.identity()
      output.push({ mount: id, base_game: base, selected: output.is_empty(), })
    }
  }
  output
}

///|
pub fn GameFileSystem::read(
  self : GameFileSystem,
  path : String,
) -> MountedFile raise {
  let path = game_virtual_path(path)
  for mount in self.mounts {
    let bytes = match mount {
      LooseMount(_, _, exists, read) =>
        if exists(path) {
          Some(read(path))
        } else {
          None
        }
      VPKMount(_, _, archive, loader) =>
        if archive.find(path) is Some(_) {
          Some(archive.read(path, loader))
        } else {
          None
        }
    }
    if bytes is Some(bytes) {
      if bytes.length() > self.max_bytes {
        raise InvalidReference("Mounted file exceeds byte budget")
      }
      let (id, base_game) = mount.identity()
      return { path, mount: id, base_game, bytes, }
    }
  }
  raise InvalidReference("Game file was not found: " + path)
}

///|
pub(all) struct GameSearchPath {
  ids : Array[String]
  directory : String
  wildcard : Bool
} derive(Eq, Debug)

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

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

///|
fn game_child(entries : Array[Entry], key : String) -> Value? {
  for entry in entries {
    if entry.key.to_lower() == key.to_lower() {
      return Some(entry.value)
    }
  }
  None
}

///|
/// Expand known gameinfo tokens using explicit roots. Wildcard paths are plans
/// for the caller to enumerate and mount, not permission to scan the host disk.
pub fn gameinfo_search_paths(
  document : Document,
  gameinfo_directory : String,
  engine_directory : String,
  language~ : String = "english",
) -> Array[GameSearchPath] raise {
  if gameinfo_directory.is_empty() ||
    engine_directory.is_empty() ||
    language.is_empty() ||
    language
    .to_array()
    .any(c => !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_')) {
    raise InvalidReference("Invalid explicit gameinfo roots or language")
  }
  guard game_child(document.entries, "GameInfo") is Some(Object(info)) &&
    game_child(info, "FileSystem") is Some(Object(filesystem)) &&
    game_child(filesystem, "SearchPaths") is Some(Object(paths)) else {
    raise InvalidReference("GameInfo/FileSystem/SearchPaths was not found")
  }
  if paths.length() > 128 {
    raise InvalidReference("Gameinfo search path budget exceeded")
  }
  let output : Array[GameSearchPath] = []
  for entry in paths {
    guard entry.value is Text(raw) else {
      raise InvalidReference("Search paths must be strings")
    }
    let ids = entry.key.split("+").map(x => x.to_owned().to_lower()).collect()
    if ids.is_empty() || ids.any(x => x.is_empty()) {
      raise InvalidReference("Invalid search path identifiers")
    }
    let normalized = raw
      .replace_all(old="\\", new="/")
      .replace_all(old="|language|", new=language)
    let expanded = if normalized.has_prefix("|gameinfo_path|") {
      gameinfo_directory + "/" + normalized[15:].to_owned()
    } else if normalized.has_prefix("|all_source_engine_paths|") {
      engine_directory + "/" + normalized[25:].to_owned()
    } else {
      engine_directory + "/" + normalized
    }
    if expanded.contains("|") {
      raise InvalidReference("Unsupported gameinfo search path token")
    }
    let wildcard = expanded.has_suffix("/*")
    let directory = normalize_reference(
      if wildcard {
        expanded[:expanded.length() - 2].to_owned()
      } else {
        expanded
      },
    )
    if directory.contains("*") || directory.contains("?") {
      raise InvalidReference(
        "Only a trailing gameinfo /* wildcard is supported",
      )
    }
    output.push({ ids, directory, wildcard, })
  }
  output
}