///|
fn macos_staging_root(spec : PackageSpec) -> String {
  path_join(spec.output, "." + executable_name(spec.product_name) + ".staging")
}

///|
fn macos_app_path(spec : PackageSpec) -> String {
  path_join(spec.output, spec.product_name + ".app")
}

///|
fn macos_zip_path(spec : PackageSpec) -> String {
  path_join(spec.output, spec.product_name + ".app.zip")
}

///|
fn macos_dmg_path(spec : PackageSpec) -> String {
  path_join(spec.output, spec.product_name + ".dmg")
}

///|
fn macos_info_plist(
  spec : PackageSpec,
  customization : PackageCustomization,
  executable : String,
  short_version : String,
  bundle_version : String,
) -> String {
  let custom_properties = StringBuilder::new()
  for property in customization.macos_plist_strings {
    custom_properties <+
      $|\{xml_escape(property.key)}\{xml_escape(property.value)}
  }
  let schemes = StringBuilder::new()
  for scheme in spec.url_schemes {
    schemes <+
      $|\{xml_escape(scheme)}
  }
  let url_types = if schemes.to_string() == "" {
    ""
  } else {
    "CFBundleURLTypes" +
    "CFBundleURLName" +
    xml_escape(spec.identifier) +
    "CFBundleURLSchemes" +
    schemes.to_string() +
    ""
  }
  let document_entries = StringBuilder::new()
  for document_type in spec.document_types {
    document_entries <+
      $|CFBundleTypeName\{xml_escape(document_type.name)}CFBundleTypeRole\{xml_escape(document_type.role)}CFBundleTypeExtensions
    for extension in document_type.extensions {
      document_entries <+
        $|\{xml_escape(extension)}
    }
    document_entries <+
      "LSHandlerRankOwner"
  }
  let document_types = if document_entries.to_string() == "" {
    ""
  } else {
    "CFBundleDocumentTypes" +
    document_entries.to_string() +
    ""
  }
  let icon = match first_icon(spec, ".icns") {
    Some(_) => "CFBundleIconFileAppIcon"
    None => ""
  }
  let template =
    #|
    #|
    #|
    #|CFBundleDisplayName{{PRODUCT}}
    #|CFBundleExecutable{{EXECUTABLE}}
    #|CFBundleIdentifier{{IDENTIFIER}}
    #|CFBundleName{{PRODUCT}}
    #|CFBundlePackageTypeAPPL
    #|CFBundleShortVersionString{{SHORT_VERSION}}
    #|CFBundleVersion{{BUNDLE_VERSION}}
    #|LSMinimumSystemVersion12.0
    #|NSHighResolutionCapable
    #|{{ICON}}{{URL_TYPES}}{{DOCUMENT_TYPES}}{{CUSTOM_PROPERTIES}}
    #|
    #|
  template
  .replace_all(old="{{PRODUCT}}", new=xml_escape(spec.product_name))
  .replace_all(old="{{EXECUTABLE}}", new=xml_escape(executable))
  .replace_all(old="{{IDENTIFIER}}", new=xml_escape(spec.identifier))
  .replace_all(old="{{SHORT_VERSION}}", new=xml_escape(short_version))
  .replace_all(old="{{BUNDLE_VERSION}}", new=xml_escape(bundle_version))
  .replace_all(old="{{ICON}}", new=icon)
  .replace_all(old="{{URL_TYPES}}", new=url_types)
  .replace_all(old="{{DOCUMENT_TYPES}}", new=document_types)
  .replace_all(old="{{CUSTOM_PROPERTIES}}", new=custom_properties.to_string())
}

///|
fn macos_versions(value : String) -> (String, String) raise PackagePlanError {
  let value = value.trim().to_owned()
  let core = match value.split_once("-") {
    Some((core, _)) => core.to_owned()
    None => value
  }
  let core = match core.split_once("+") {
    Some((core, _)) => core.to_owned()
    None => core
  }
  let components : Array[StringView] = core.split(".").collect()
  guard components.length() >= 1 && components.length() <= 3 else {
    raise InvalidVersion(
      version=core,
      reason="macOS version must contain one to three numeric components",
    )
  }
  let normalized : Array[String] = []
  for component in components {
    guard component.length() > 0 else {
      raise InvalidVersion(version=core, reason="contains an empty component")
    }
    for char in component {
      guard char.is_ascii_digit() else {
        raise InvalidVersion(version=core, reason="components must be numeric")
      }
    }
    normalized.push(component.to_owned())
  }
  while normalized.length() < 3 {
    normalized.push("0")
  }
  let short_version = normalized.join(".")
  let bundle_version = match @env.get_env_var("PROTON_MACOS_BUILD_NUMBER") {
    Some(value) if value.trim().to_owned() != "" => value.trim().to_owned()
    _ => short_version
  }
  (short_version, bundle_version)
}

///|
fn macos_signing_identity(spec : PackageSpec) -> String raise MacosSigningError {
  if !spec.sign {
    return "-"
  }
  let identity = match @env.get_env_var("PROTON_MACOS_SIGNING_IDENTITY") {
    Some(identity) if identity.trim().to_owned() != "" =>
      identity.trim().to_owned()
    _ =>
      raise MacosSigningError::InvalidConfiguration(
        detail="signing requires PROTON_MACOS_SIGNING_IDENTITY",
      )
  }
  if identity == "-" {
    guard macos_adhoc_signing_allowed() else {
      raise MacosSigningError::InvalidConfiguration(
        detail="ad-hoc signing is not a release path; set PROTON_MACOS_ALLOW_ADHOC=1 only for local diagnostics",
      )
    }
    guard !spec.notarize else {
      raise MacosSigningError::InvalidConfiguration(
        detail="notarization requires a Developer ID Application identity",
      )
    }
  }
  identity
}

///|
fn macos_adhoc_signing_allowed() -> Bool {
  match @env.get_env_var("PROTON_MACOS_ALLOW_ADHOC") {
    Some(value) =>
      match value.trim().to_owned().to_lower() {
        "1" | "true" | "yes" | "on" => true
        _ => false
      }
    None => false
  }
}

///|
async fn run_macos_tool(
  tool : String,
  args : Array[String],
  stage : String,
) -> Unit {
  let (code, output) = @process.collect_output_merged(tool, args) catch {
    error =>
      raise MacosSigningError::ToolFailure(
        stage~,
        detail=@debug.render(Repr(error)),
      )
  }
  guard code == 0 else {
    raise MacosSigningError::ToolFailure(
      stage~,
      detail="exit code " +
        code.to_string() +
        ": " +
        (output.text() catch { _ => "non-UTF-8 output" }),
    )
  }
}

///|
async fn sign_macos_app(
  spec : PackageSpec,
  customization : PackageCustomization,
  layout : PackageLayout,
) -> Unit {
  let identity = macos_signing_identity(spec)
  let entitlements = resolve_macos_entitlements(
    layout.resources,
    allow_untrusted_libraries=identity == "-",
  )
  for target in customization.macos_sign_targets {
    let path = path_join(layout.root, target.path)
    guard path_exists(path) else {
      raise MacosSigningError::Verification(
        detail="required signing target is missing: " + path,
      )
    }
    codesign_macos_target(
      identity,
      path,
      runtime_options=target.runtime_options,
      entitlements=if target.entitlements { Some(entitlements) } else { None },
      identifier=target.identifier,
    )
  }
  codesign_macos_target(
    identity,
    layout.executable,
    runtime_options=true,
    entitlements=Some(entitlements),
    identifier=None,
  )
  codesign_macos_target(
    identity,
    layout.root,
    runtime_options=true,
    entitlements=Some(entitlements),
    identifier=None,
  )
  run_macos_tool(
    "/usr/bin/codesign",
    macos_codesign_verify_args(layout.root, deep=true),
    "verify application signature",
  )
  if spec.sign && identity != "-" {
    verify_macos_developer_id(layout.root)
  }
}

///|
async fn codesign_macos_target(
  identity : String,
  target : String,
  runtime_options~ : Bool,
  entitlements~ : String?,
  identifier~ : String?,
) -> Unit {
  let args = macos_codesign_args(
    identity,
    target,
    runtime_options~,
    entitlements~,
    identifier~,
  )
  run_macos_tool("/usr/bin/codesign", args, "sign " + target)
}

///|
fn macos_codesign_args(
  identity : String,
  target : String,
  runtime_options~ : Bool,
  entitlements~ : String?,
  identifier~ : String?,
) -> Array[String] {
  let args = ["--force"]
  if identity != "-" {
    args.push("--timestamp")
  }
  if runtime_options {
    args.append(["--options", "runtime"])
  }
  match entitlements {
    Some(path) if runtime_options => args.append(["--entitlements", path])
    _ => ()
  }
  match identifier {
    Some(value) => args.append(["-i", value])
    None => ()
  }
  args.append(["--sign", identity, target])
  args
}

///|
fn macos_codesign_verify_args(target : String, deep~ : Bool) -> Array[String] {
  let args = ["--verify"]
  if deep {
    args.push("--deep")
  }
  args.append(["--strict", "--verbose=2", target])
  args
}

///|
async fn verify_macos_developer_id(app : String) -> Unit {
  let (code, output) = @process.collect_output_merged("/usr/bin/codesign", [
    "--display", "--verbose=4", app,
  ]) catch {
    error =>
      raise MacosSigningError::Verification(
        detail="failed to inspect signing authority: " +
          @debug.render(Repr(error)),
      )
  }
  guard code == 0 else {
    raise MacosSigningError::Verification(
      detail="failed to inspect signing authority",
    )
  }
  let text = output.text() catch {
    error =>
      raise MacosSigningError::Verification(
        detail="failed to decode signing authority: " +
          @debug.render(Repr(error)),
      )
  }
  guard text.contains("Authority=Developer ID Application:") else {
    raise MacosSigningError::Verification(
      detail="release signing must use a Developer ID Application identity",
    )
  }
}

///|
async fn resolve_macos_entitlements(
  resources : String,
  allow_untrusted_libraries~ : Bool,
) -> String {
  match @env.get_env_var("PROTON_MACOS_ENTITLEMENTS") {
    Some(path) if path.trim().length() > 0 => {
      let path = @path.Path(path.trim().to_owned()).resolve().to_string()
      guard path_is_file(path) else {
        raise MacosSigningError::InvalidConfiguration(
          detail="macOS entitlements file is missing: " + path,
        )
      }
      path
    }
    _ => {
      let path = path_join(resources, "proton.entitlements")
      write_text(path, default_macos_entitlements(allow_untrusted_libraries~))
      path
    }
  }
}

///|
fn default_macos_entitlements(allow_untrusted_libraries~ : Bool) -> String {
  let library_validation = if allow_untrusted_libraries {
    "com.apple.security.cs.disable-library-validation\n"
  } else {
    ""
  }
  (
    #|
    #|
    #|
    #|com.apple.security.cs.allow-jit
    #|com.apple.security.cs.allow-unsigned-executable-memory
    #|{{LIBRARY_VALIDATION}}
    #|
    #|
  ).replace_all(old="{{LIBRARY_VALIDATION}}", new=library_validation)
}

///|
async fn create_macos_zip(app : String, destination : String) -> Unit {
  let staging = destination + ".staging"
  remove_tree(staging)
  errdefer (remove_tree(staging) catch { _ => () })
  run_macos_tool(
    "/usr/bin/ditto",
    ["-c", "-k", "--sequesterRsrc", "--keepParent", app, staging],
    "create application zip",
  )
  promote(staging, destination)
}

///|
async fn create_macos_dmg(
  spec : PackageSpec,
  app : String,
  destination : String,
) -> Unit {
  let root = path_join(macos_staging_root(spec), "dmg-root")
  // hdiutil chooses its output format from the final extension.
  let staging = destination + ".staging.dmg"
  remove_tree(root)
  remove_tree(staging)
  ensure_dir(root)
  errdefer {
    remove_tree(root) catch {
      _ => ()
    }
    remove_tree(staging) catch {
      _ => ()
    }
  }
  copy_tree(app, path_join(root, spec.product_name + ".app"))
  @async_fs.symlink(target="/Applications", path_join(root, "Applications")) catch {
    error =>
      raise PackageFileSystemError::Write(
        path=root,
        detail=@debug.render(Repr(error)),
      )
  }
  run_macos_tool(
    "/usr/bin/hdiutil",
    [
      "create",
      "-volname",
      spec.product_name,
      "-srcfolder",
      root,
      "-format",
      "UDZO",
      "-ov",
      staging,
    ],
    "create DMG",
  )
  if spec.sign {
    run_macos_tool(
      "/usr/bin/codesign",
      [
        "--force",
        "--timestamp",
        "--sign",
        macos_signing_identity(spec),
        staging,
      ],
      "sign DMG",
    )
    run_macos_tool(
      "/usr/bin/codesign",
      ["--verify", "--strict", staging],
      "verify DMG signature",
    )
  }
  run_macos_tool("/usr/bin/hdiutil", ["verify", staging], "verify DMG")
  promote(staging, destination)
  remove_tree(root)
}

///|
async fn notarize_macos(
  spec : PackageSpec,
  app : String,
  artifact : String,
) -> Unit {
  guard spec.notarize else { return }
  let profile = match
    (
      @env.get_env_var("PROTON_MACOS_NOTARY_PROFILE"),
      @env.get_env_var("PROTON_NOTARY_PROFILE"),
    ) {
    (Some(profile), _) if profile.trim().to_owned() != "" =>
      profile.trim().to_owned()
    (_, Some(profile)) if profile.trim().to_owned() != "" =>
      profile.trim().to_owned()
    _ =>
      raise MacosSigningError::InvalidConfiguration(
        detail="notarization requires PROTON_MACOS_NOTARY_PROFILE or PROTON_NOTARY_PROFILE",
      )
  }
  run_macos_tool(
    "/usr/bin/xcrun",
    ["notarytool", "submit", artifact, "--keychain-profile", profile, "--wait"],
    "submit notarization",
  )
  run_macos_tool(
    "/usr/bin/xcrun",
    ["stapler", "staple", app],
    "staple application",
  )
  run_macos_tool(
    "/usr/bin/xcrun",
    ["stapler", "validate", app],
    "validate application ticket",
  )
  if artifact.has_suffix(".dmg") {
    run_macos_tool(
      "/usr/bin/xcrun",
      ["stapler", "staple", artifact],
      "staple DMG",
    )
    run_macos_tool(
      "/usr/bin/xcrun",
      ["stapler", "validate", artifact],
      "validate DMG ticket",
    )
  }
  run_macos_tool(
    "/usr/sbin/spctl",
    ["--assess", "--type", "execute", "--verbose=4", app],
    "assess notarized application",
  )
}

///|
async fn package_macos(
  spec : PackageSpec,
  customization : PackageCustomization,
  prepare : async (PackageLayout) -> Unit,
) -> Array[String] {
  guard path_is_file(spec.executable) else {
    raise PackagePlanError::MissingExecutable(path=spec.executable)
  }
  let staging_root = macos_staging_root(spec)
  let app = path_join(staging_root, spec.product_name + ".app")
  let contents = path_join(app, "Contents")
  let macos = path_join(contents, "MacOS")
  let resources = path_join(contents, "Resources")
  let frameworks = path_join(contents, "Frameworks")
  remove_tree(staging_root)
  errdefer (remove_tree(staging_root) catch { _ => () })
  for directory in [macos, resources, frameworks] {
    ensure_dir(directory)
  }
  let name = executable_name(spec.product_name)
  let staged_executable = path_join(macos, name)
  copy_file(spec.executable, staged_executable)
  @async_fs.chmod(staged_executable, 0o755)
  copy_payloads(spec, resources, frameworks, macos)
  match first_icon(spec, ".icns") {
    Some(icon) => copy_file(icon, path_join(resources, "AppIcon.icns"))
    None => ()
  }
  let layout = PackageLayout::{
    root: app,
    executable: staged_executable,
    resources,
    libraries: frameworks,
    executables: macos,
  }
  prepare(layout)
  let (short_version, bundle_version) = macos_versions(spec.version)
  write_text(
    path_join(contents, "Info.plist"),
    macos_info_plist(spec, customization, name, short_version, bundle_version),
  )
  sign_macos_app(spec, customization, layout)
  let outputs : Array[String] = []
  let zip = macos_zip_path(spec)
  let dmg = macos_dmg_path(spec)
  if spec.notarize && !spec.formats.contains(Dmg) {
    create_macos_zip(app, zip)
  }
  if spec.formats.contains(Dmg) {
    create_macos_dmg(spec, app, dmg)
    outputs.push(dmg)
  }
  if spec.notarize {
    notarize_macos(
      spec,
      app,
      if spec.formats.contains(Dmg) {
        dmg
      } else {
        zip
      },
    )
  }
  if spec.formats.contains(Zip) {
    // Recreate after notarization so the archive contains the stapled app.
    create_macos_zip(app, zip)
    outputs.push(zip)
  } else if spec.notarize && !spec.formats.contains(Dmg) {
    remove_tree(zip)
  }
  if spec.formats.contains(App) {
    let destination = macos_app_path(spec)
    promote(app, destination)
    outputs.push(destination)
  }
  remove_tree(staging_root)
  outputs
}