///|
/// Is this file an nFPM configuration (`nfpm.yaml`)?
fn is_nfpm_config(path : String) -> Bool {
  path.has_suffix(".yaml") || path.has_suffix(".yml")
}

///|
/// What the nFPM reader may ask of this machine: environment variables and
/// files (relative to the current directory, like nFPM).
fn cli_host() -> @nfpm.Host {
  {
    getenv: name => @env.get_env_var(name),
    read_file: path => {
      try @fs.read_file_to_bytes(path) catch {
        _ => None
      } noraise {
        data => Some(data)
      }
    },
    list_dir: path => {
      let is_directory = try @fs.is_dir(path) catch {
        _ => false
      } noraise {
        yes => yes
      }
      if is_directory {
        try @fs.read_dir(path) catch {
          _ => None
        } noraise {
          names => Some(names.to_owned())
        }
      } else {
        None
      }
    },
  }
}

///|
/// Read an nFPM configuration for one packager and print what could not be
/// carried over. `printed` holds the warnings shown already, so that building
/// several formats does not repeat them.
fn convert_nfpm(
  config_path : String,
  format : String,
  printed : Array[String],
) -> @nfpm.Converted raise CliError {
  let text = @fs.read_file_to_string(config_path) catch {
    err => raise Failed(describe_io(err, "cannot read \"\{config_path}\""))
  }
  let converted = @nfpm.convert(text, cli_host(), format~) catch {
    err => raise Failed("\{config_path}: \{err}")
  }
  for warning in converted.warnings {
    if !printed.contains(warning) {
      printed.push(warning)
      eprintln("linuxpkg: warning: \{warning}")
    }
  }
  converted
}

///|
fn load_nfpm(
  config_path : String,
  format : String,
  printed : Array[String],
) -> @spec.Config raise CliError {
  let converted = convert_nfpm(config_path, format, printed)
  @spec.Config::parse(converted.config.stringify()) catch {
    err => raise Failed("\{config_path}: \{err}")
  }
}

///|
/// The configuration of `config_path` resolved to a package. `nfpm_format` is
/// set for an nFPM file: that packager's view of it, with files looked up
/// relative to the current directory (nFPM does so); otherwise the file is a
/// `linuxpkg.json` and its files are relative to it.
fn load_spec(
  config_path : String,
  allow_arch_mismatch~ : Bool = false,
  nfpm_format? : String,
  printed~ : Array[String] = [],
) -> (@spec.PackageSpec, @spec.Config) raise CliError {
  let (parsed, base) = match nfpm_format {
    Some(format) => (load_nfpm(config_path, format, printed), "")
    None => {
      let text = @fs.read_file_to_string(config_path) catch {
        err => raise Failed(describe_io(err, "cannot read \"\{config_path}\""))
      }
      let parsed = @spec.Config::parse(text) catch {
        err => raise Failed("\{config_path}: \{err}")
      }
      (parsed, dirname(config_path))
    }
  }
  let config = if allow_arch_mismatch {
    { ..parsed, allow_arch_mismatch: true, }
  } else {
    parsed
  }
  let files : Map[String, Bytes] = Map([])
  for source in config.sources() {
    files[source] = read_bytes(join_path(base, source))
  }
  let default_mtime = match source_date_epoch() {
    Some(t) => t
    None => 0L
  }
  let spec = config.resolve(files, default_mtime~) catch {
    err => raise Failed("\{config_path}: \{err}")
  }
  for warning in config.elf_warnings(files) {
    if !printed.contains(warning) {
      printed.push(warning)
      eprintln("linuxpkg: warning: \{warning}")
    }
  }
  (spec, config)
}

///|
fn cmd_build(args : Array[String]) -> Int32 raise CliError {
  let options = parse_options(
    args,
    {
      "--format": "format",
      "-f": "format",
      "--out": "out",
      "-o": "out",
      "--compression": "compression",
      "-z": "compression",
      "--sign-key": "sign_key",
      "--apk-key-name": "apk_key_name",
    },
    { "--allow-arch-mismatch": "allow_arch_mismatch" },
  )
  let config_path = match options.positional {
    [path] => path
    [] => raise Usage("build needs the path of a linuxpkg.json")
    _ => raise Usage("build takes exactly one configuration file")
  }
  let format = match options.value("format") {
    Some(f) => f
    None => "all"
  }
  if format != "deb" &&
    format != "rpm" &&
    format != "arch" &&
    format != "apk" &&
    format != "all" {
    raise Usage(
      "unknown format \"\{format}\", expected deb, rpm, arch, apk or all",
    )
  }
  let forced = match options.value("compression") {
    Some(name) =>
      match @compress.Compression::parse(name) {
        Some(c) => Some(c)
        None =>
          raise Usage(
            "unknown compression \"\{name}\", expected none, gzip, xz or zstd",
          )
      }
    None => None
  }
  let allow_mismatch = options.flag("allow_arch_mismatch")
  let out_dir = match options.value("out") {
    Some(dir) => dir
    None => ""
  }
  if is_nfpm_config(config_path) {
    // One build per packager: nFPM's overrides differ for each of them.
    let printed : Array[String] = []
    let all = format == "all"
    let wanted = match format {
      "all" => ["deb", "rpm", "archlinux", "apk"]
      "arch" => ["archlinux"]
      other => [other]
    }
    let failures : Array[String] = []
    for packager in wanted {
      let own = if packager == "archlinux" { "arch" } else { packager }
      try {
        let (spec, config) = load_spec(
          config_path,
          allow_arch_mismatch=allow_mismatch,
          nfpm_format=packager,
          printed~,
        )
        build_formats(
          config_path,
          "",
          spec,
          config,
          own,
          !all,
          forced,
          options.value("sign_key"),
          options.value("apk_key_name"),
          out_dir,
        )
      } catch {
        Failed(message) =>
          if all {
            // The other packagers still get their turn.
            // The same complaint about the whole file is printed once.
            let text = message.replace_all(old=config_path + ": ", new="")
            if !printed.contains(text) {
              printed.push(text)
              eprintln("linuxpkg: \{own}: \{message}")
            }
            failures.push(own)
          } else {
            raise Failed(message)
          }
        other => raise other
      } noraise {
        _ => ()
      }
    }
    if failures.length() > 0 {
      raise Failed("no package for \{failures.join(", ")}")
    }
  } else {
    let (spec, config) = load_spec(
      config_path,
      allow_arch_mismatch=allow_mismatch,
    )
    build_formats(
      config_path,
      dirname(config_path),
      spec,
      config,
      format,
      format != "all",
      forced,
      options.value("sign_key"),
      options.value("apk_key_name"),
      out_dir,
    )
  }
  0
}

///|
/// Write the packages of `format` (`deb`, `rpm`, `arch`, `apk`, or `all`).
/// With `strict` a package that does not fit its format (no Arch Linux port
/// for the architecture, ...) is an error; otherwise it is skipped with a note.
/// `base` is the directory of the configuration, which `sign_key` is relative to.
fn build_formats(
  config_path : String,
  base : String,
  spec : @spec.PackageSpec,
  config : @spec.Config,
  format : String,
  strict : Bool,
  forced : @compress.Compression?,
  sign_option : String?,
  key_name_option : String?,
  out_dir : String,
) -> Unit raise CliError {
  // --sign-key wins over "sign_key" in the configuration, whose path is
  // relative to the configuration file.
  let key_path = match (sign_option, config.sign_key) {
    (Some(path), _) => Some(path)
    (None, Some(path)) => Some(join_path(base, path))
    (None, None) => None
  }
  let key = match key_path {
    Some(path) => Some(load_signing_key(path))
    None => None
  }
  let key_name = match (key_name_option, config.apk_key_name) {
    (Some(name), _) => Some(name)
    (None, Some(name)) => Some(name)
    (None, None) => None
  }
  ensure_dir(out_dir)
  if format == "deb" || format == "all" {
    let compression = match forced {
      Some(c) => c
      None => config.deb_compression()
    }
    let deb = @deb.build(spec, compression~) catch {
      err => raise Failed("\{config_path}: \{err}")
    }
    let target = join_path(out_dir, @deb.file_name(spec))
    write_bytes(target, deb)
    println("wrote \{target} (\{deb.length()} bytes)")
  }
  if format == "rpm" || format == "all" {
    let compression = match forced {
      Some(c) => c
      None => config.rpm_compression()
    }
    let unsigned = @rpm.build(spec, compression~) catch {
      err => raise Failed("\{config_path}: \{err}")
    }
    let rpm = match key {
      Some(key) =>
        @rpm.sign(unsigned, key, created=spec.mtime) catch {
          err => raise Failed("\{config_path}: \{err}")
        }
      None => unsigned
    }
    let target = join_path(out_dir, @rpm.file_name(spec))
    write_bytes(target, rpm)
    println("wrote \{target} (\{rpm.length()} bytes)")
  }
  if format == "arch" || format == "all" {
    let compression = match forced {
      Some(c) => c
      None => config.arch_compression()
    }
    // `all` is the Debian and RPM families plus Arch Linux where the package
    // fits Arch (it has no port of every architecture, and its release must
    // be a number); a package that does not is skipped with a note.
    try @arch.build(spec, compression~) catch {
      err =>
        if strict {
          raise Failed("\{config_path}: \{err}")
        } else {
          eprintln("linuxpkg: no Arch Linux package: \{err}")
        }
    } noraise {
      pkg => {
        let target = join_path(out_dir, @arch.file_name(spec, compression~))
        write_bytes(target, pkg)
        println("wrote \{target} (\{pkg.length()} bytes)")
        match key {
          Some(key) => {
            let signature = key.detached(
              pkg[:],
              created=key.signing_time(spec.mtime),
            ) catch {
              err => raise Failed("\{config_path}: \{err}")
            }
            write_bytes(target + ".sig", signature)
            println("wrote \{target}.sig (\{signature.length()} bytes)")
          }
          None => ()
        }
      }
    }
  }
  if format == "apk" || format == "all" {
    // apk packages are always gzip, so --compression does not apply.
    try @apk.build(spec) catch {
      err =>
        if strict {
          raise Failed("\{config_path}: \{err}")
        } else {
          eprintln("linuxpkg: no Alpine Linux package: \{err}")
        }
    } noraise {
      unsigned => {
        // Alpine only knows RSA signatures: an Ed25519 key signs the other
        // formats and leaves the apk unsigned (an error if apk was asked for).
        let signing = match key {
          Some(k) =>
            if k.is_rsa() {
              Some(k)
            } else if strict {
              raise Failed(
                "apk packages can only be signed with an RSA key, \{key_path.unwrap()} is an Ed25519 key",
              )
            } else {
              eprintln(
                "linuxpkg: the apk package is not signed: Alpine only supports RSA keys",
              )
              None
            }
          None => None
        }
        let pkg = match signing {
          Some(k) =>
            @apk.sign(
              unsigned,
              k,
              key_name=apk_key_name(k, key_name),
              created=spec.mtime,
            ) catch {
              err => raise Failed("\{config_path}: \{err}")
            }
          None => unsigned
        }
        let target = join_path(out_dir, @apk.file_name(spec))
        write_bytes(target, pkg)
        println("wrote \{target} (\{pkg.length()} bytes)")
      }
    }
  }
}