///|
fn host_path(value : String) -> @path.Path {
  if @path.sep == '\\' {
    value.replace_all(old="/", new="\\")
  } else {
    value
  }
}

///|
fn resolve_path(base : String, value : String) -> String {
  let path = host_path(value)
  if path.is_absolute() {
    path.resolve().to_string()
  } else {
    host_path(base).join(path).resolve().to_string()
  }
}

///|
fn path_parent(value : String) -> String {
  host_path(value).dirname().to_string()
}

///|
fn relative_path(base : String, value : String) -> String {
  host_path(value)
  .resolve()
  .relative(base=host_path(base).resolve())
  .to_string()
}

///|
async fn path_exists(value : String) -> Bool {
  @async_fs.exists(value)
}

///|
async fn path_is_file(value : String) -> Bool {
  if !path_exists(value) {
    return false
  }
  @async_fs.kind(value) is Regular
}

///|
async fn path_is_dir(value : String) -> Bool {
  if !path_exists(value) {
    return false
  }
  @async_fs.kind(value) is Directory
}

///|
fn relative_to_base(base : String, value : String) -> String? {
  let root = resolve_path(base, ".")
  let candidate = resolve_path(base, value)
  let relative = relative_path(root, candidate).replace_all(old="\\", new="/")
  if relative == "" {
    Some(".")
  } else if relative == ".." || relative.has_prefix("../") {
    None
  } else {
    Some(relative)
  }
}

///|
fn glob_tree_root(value : String) -> String? {
  let normalized = value.replace_all(old="\\", new="/")
  if normalized.has_suffix("/**") {
    Some(value.unsafe_substring(start=0, end=value.length() - 3).to_string())
  } else {
    None
  }
}

///|
async fn current_bundle_host() -> BundleHost {
  if @path.sep == '\\' {
    Windows
  } else if path_exists("/System/Library/CoreServices/SystemVersion.plist") {
    Macos
  } else {
    Linux
  }
}

///|
async fn ensure_dir(path : String) -> Unit {
  guard !path_exists(path) else { return }
  let parent = path_parent(path)
  if parent != path && !path_exists(parent) {
    ensure_dir(parent)
  }
  @async_fs.mkdir(path) catch {
    error => {
      if path_exists(path) {
        return
      }
      raise BundleFileSystemError::CreateDirectory(
        path~,
        detail=@debug.render(Repr(error)),
      )
    }
  }
}

///|
async fn copy_file(source : String, destination : String) -> Unit {
  ensure_dir(path_parent(destination))
  if current_bundle_host() == Macos {
    let code = @process.run("ditto", [source, destination]) catch {
      error =>
        raise BundleFileSystemError::Copy(
          source~,
          destination~,
          detail=@debug.render(Repr(error)),
        )
    }
    guard code == 0 else {
      raise BundleToolError::Exit(tool="ditto", code~, detail=source)
    }
    return
  }
  let data = @async_fs.read_file(source) catch {
    error =>
      raise BundleFileSystemError::Read(
        path=source,
        detail=@debug.render(Repr(error)),
      )
  }
  @async_fs.write_file(destination, data, create_mode=CreateOrTruncate) catch {
    error =>
      raise BundleFileSystemError::Write(
        path=destination,
        detail=@debug.render(Repr(error)),
      )
  }
}

///|
async fn write_text(path : String, text : String) -> Unit {
  @async_fs.write_file(path, text, create_mode=CreateOrTruncate) catch {
    error =>
      raise BundleFileSystemError::Write(
        path~,
        detail=@debug.render(Repr(error)),
      )
  }
}

///|
const MachoExecute : Int = 0x2

///|
fn macho_filetype_name(filetype : Int) -> String {
  match filetype {
    0x1 => "MH_OBJECT"
    0x2 => "MH_EXECUTE"
    0x6 => "MH_DYLIB"
    0x8 => "MH_BUNDLE"
    0xa => "MH_DSYM"
    _ => "0x" + filetype.to_string()
  }
}

///|
fn macho_u32_le(data : Bytes, offset : Int) -> Int {
  data[offset].to_int() |
  (data[offset + 1].to_int() << 8) |
  (data[offset + 2].to_int() << 16) |
  (data[offset + 3].to_int() << 24)
}

///|
fn macho_thin_filetype(data : Bytes) -> Int? {
  guard data.length() >= 16 else { return None }
  let magic = macho_u32_le(data, 0)
  guard magic == 0xfeedfacf || magic == 0xfeedface else { return None }
  Some(macho_u32_le(data, 12))
}

///|
async fn require_launchable_macho(path : String) -> Unit {
  let data = @async_fs.read_file(path) catch {
    error =>
      raise BundleFileSystemError::Read(
        path~,
        detail=@debug.render(Repr(error)),
      )
  }
  guard macho_thin_filetype(data.binary()) is Some(filetype) else { return }
  guard filetype != MachoExecute else { return }
  raise BundlePlanError::NotLaunchableMachO(path~, filetype~)
}

///|
fn xml_escape(text : String) -> String {
  text
  .replace_all(old="&", new="&")
  .replace_all(old="<", new="<")
  .replace_all(old=">", new=">")
  .replace_all(old="\"", new=""")
}

///|
fn macos_helper_specs(
  product_name : String,
  identifier : String,
) -> Array[MacosHelperSpec] {
  let base_name = product_name + " Helper"
  let base_identifier = identifier + ".helper"
  [
    MacosHelperSpec::{
      bundle_name: base_name,
      executable_name: base_name,
      bundle_identifier: base_identifier,
    },
    MacosHelperSpec::{
      bundle_name: base_name + " (Alerts)",
      executable_name: base_name + " (Alerts)",
      bundle_identifier: base_identifier + ".alerts",
    },
    MacosHelperSpec::{
      bundle_name: base_name + " (GPU)",
      executable_name: base_name + " (GPU)",
      bundle_identifier: base_identifier + ".gpu",
    },
    MacosHelperSpec::{
      bundle_name: base_name + " (Plugin)",
      executable_name: base_name + " (Plugin)",
      bundle_identifier: base_identifier + ".plugin",
    },
    MacosHelperSpec::{
      bundle_name: base_name + " (Renderer)",
      executable_name: base_name + " (Renderer)",
      bundle_identifier: base_identifier + ".renderer",
    },
  ]
}

///|
fn helper_info_plist(
  spec : ProtonBundleSpec,
  helper : MacosHelperSpec,
) -> String {
  let template =
    #|
    #|
    #|
    #|CFBundleExecutable{{EXECUTABLE}}
    #|CFBundleIdentifier{{IDENTIFIER}}
    #|CFBundleName{{NAME}}
    #|CFBundlePackageTypeAPPL
    #|CFBundleShortVersionString{{MACOS_VERSION}}
    #|CFBundleVersion{{BUNDLE_VERSION}}
    #|
    #|
  template
  .replace_all(old="{{EXECUTABLE}}", new=xml_escape(helper.executable_name))
  .replace_all(old="{{IDENTIFIER}}", new=xml_escape(helper.bundle_identifier))
  .replace_all(old="{{NAME}}", new=xml_escape(helper.bundle_name))
  .replace_all(old="{{MACOS_VERSION}}", new=xml_escape(spec.macos_version))
  .replace_all(old="{{BUNDLE_VERSION}}", new=xml_escape(spec.bundle_version))
}

///|
async fn stage_update_revision(
  spec : ProtonBundleSpec,
  destination : String,
) -> Unit {
  write_text(
    resolve_path(destination, "proton-update-revision"),
    "\{spec.update_revision}\n",
  )
}

///|
async fn write_package_manifest(
  spec : ProtonBundleSpec,
  destination : String,
  kind : String,
) -> Unit {
  let manifest : Json = {
    "schema_version": 1,
    "kind": kind,
    "product_name": spec.package_spec.product_name,
    "identifier": spec.package_spec.identifier,
    "version": spec.package_spec.version,
    "bundle_version": spec.bundle_version,
    "targets": spec.package_spec.formats.map(fn(format) { format.to_string() }),
    "url_schemes": spec.package_spec.url_schemes,
    "document_types": spec.package_spec.document_types.map(fn(
      document_type,
    ) -> Json {
      {
        "name": document_type.name(),
        "extensions": document_type.extensions(),
        "role": document_type.role(),
      }
    }),
  }
  write_text(
    resolve_path(destination, "proton-package.json"),
    manifest.stringify() + "\n",
  )
}