///|
enum CliError {
  MissingArgument(String)
  InvalidFormat(String)
  UnknownMagic
  AmbiguousBinary
  Io(String)
  Archive(@cpio.CpioError)
  UnsafePath(String, @cpio.ArchivePathError)
  FilePathHasTrailingSlash(String)
  UnsupportedFileType(String, UInt64)
  DestinationExists(String)
  DestinationParentMissing(String)
} derive(Eq, Debug)

///|
fn format_option() -> @argparse.OptionArg {
  @argparse.OptionArg(
    "format",
    long="format",
    about="Archive format: auto, newc, odc, binary-old, binary-new-le, binary-new-be",
    default_values=["auto"],
  )
}

///|
fn cli_command() -> @argparse.Command {
  @argparse.Command(
    "moon-cpio",
    about="Inspect, validate, and safely extract cpio archives.",
    version="0.1.0",
    subcommand_required=true,
    arg_required_else_help=true,
    subcommands=[
      @argparse.Command(
        "list",
        about="List archive entries.",
        options=[format_option()],
        positionals=[@argparse.PositionArg("archive")],
      ),
      @argparse.Command(
        "validate",
        about="Validate an archive and all entry checksums.",
        options=[format_option()],
        positionals=[@argparse.PositionArg("archive")],
      ),
      @argparse.Command(
        "extract",
        about="Safely extract regular files and directories.",
        options=[format_option()],
        positionals=[
          @argparse.PositionArg("archive"),
          @argparse.PositionArg("destination"),
        ],
      ),
    ],
  )
}

///|
fn first_value(matches : @argparse.Matches, name : String) -> String {
  match matches.values.get(name) {
    Some(values) => if values.length() > 0 { values[0] } else { "" }
    None => ""
  }
}

///|
fn resolve_format(
  input : Bytes,
  name : String,
) -> Result[@cpio.CpioFormat, CliError] {
  match name {
    "newc" => Ok(@cpio.CpioFormat::Newc)
    "odc" => Ok(@cpio.CpioFormat::Odc)
    "binary-old" => Ok(@cpio.CpioFormat::BinaryOld)
    "binary-new-le" =>
      Ok(@cpio.CpioFormat::BinaryNew(@cpio.BinaryByteOrder::Little))
    "binary-new-be" =>
      Ok(@cpio.CpioFormat::BinaryNew(@cpio.BinaryByteOrder::Big))
    "auto" => detect_format(input)
    _ => Err(InvalidFormat(name))
  }
}

///|
fn detect_format(input : Bytes) -> Result[@cpio.CpioFormat, CliError] {
  if input.length() >= 6 {
    match input[0:6] {
      b"070701" | b"070702" => return Ok(@cpio.CpioFormat::Newc)
      b"070707" => return Ok(@cpio.CpioFormat::Odc)
      _ => ()
    }
  }
  if input.length() >= 2 {
    if input[0] == b'\xC7' && input[1] == b'\x71' {
      return Err(AmbiguousBinary)
    }
    if input[0] == b'\x71' && input[1] == b'\xC7' {
      return Err(AmbiguousBinary)
    }
  }
  Err(UnknownMagic)
}

///|
fn format_name(format : @cpio.CpioFormat) -> String {
  match format {
    @cpio.CpioFormat::Newc => "newc"
    @cpio.CpioFormat::Odc => "odc"
    @cpio.CpioFormat::BinaryOld => "binary-old"
    @cpio.CpioFormat::BinaryNew(@cpio.BinaryByteOrder::Little) =>
      "binary-new-le"
    @cpio.CpioFormat::BinaryNew(@cpio.BinaryByteOrder::Big) => "binary-new-be"
  }
}

///|
fn read_archive(path : String) -> Result[Bytes, CliError] {
  let bytes = @fs.read_file_to_bytes(path) catch {
    error => return Err(Io(@debug.to_string(error)))
  }
  Ok(bytes)
}

///|
fn entry_mode(header : @cpio.StreamHeader) -> UInt64 {
  match header {
    @cpio.StreamHeader::Newc(header) => header.mode
    @cpio.StreamHeader::Odc(header) => header.mode
    @cpio.StreamHeader::Binary(header) => header.mode
  }
}

///|
fn mode_type(mode : UInt64) -> String {
  match mode & 0xF000UL {
    0x4000UL => "d"
    0x8000UL => "-"
    0xA000UL => "l"
    0x2000UL => "c"
    0x6000UL => "b"
    0x1000UL => "p"
    0xC000UL => "s"
    _ => "?"
  }
}

///|
fn list_archive(
  archive_path : String,
  format_name_value : String,
) -> Result[Unit, CliError] {
  let input = match read_archive(archive_path) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let format = match resolve_format(input, format_name_value) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let result = @cpio.stream_entries(input, format, entry => {
    let mode = entry_mode(entry.header)
    println(
      "\{mode_type(mode)} \{mode.to_string(radix=8)} \{entry.data.length()} \{entry.name}",
    )
  })
  match result {
    Ok(_) => Ok(())
    Err(error) => Err(Archive(error))
  }
}

///|
fn validate_archive(
  archive_path : String,
  format_name_value : String,
) -> Result[Unit, CliError] {
  let input = match read_archive(archive_path) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let format = match resolve_format(input, format_name_value) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let mut count = 0
  let result = @cpio.stream_entries(input, format, _entry => count = count + 1)
  match result {
    Ok(_) => {
      println("OK: \{count} entries (\{format_name(format)})")
      Ok(())
    }
    Err(error) => Err(Archive(error))
  }
}

///|
fn parent_path(path : String) -> String {
  let mut index = -1
  match path.rev_find("/") {
    Some(value) => index = value
    None => ()
  }
  match path.rev_find("\\") {
    Some(value) => if value > index { index = value }
    None => ()
  }
  if index > 0 {
    path[0:index].to_owned()
  } else {
    ""
  }
}

///|
fn mkdir_all(path : String) -> Unit raise @fs.IOError {
  if path.is_empty() || @fs.path_exists(path) {
    return
  }
  let parent = parent_path(path)
  if !parent.is_empty() {
    mkdir_all(parent)
  }
  @fs.create_dir(path)
}

///|
fn preflight_extract(
  input : Bytes,
  format : @cpio.CpioFormat,
  destination : String,
) -> Result[Int32, CliError] {
  if @fs.path_exists(destination) {
    return Err(DestinationExists(destination))
  }
  let mut count = 0
  let mut failure : CliError? = None
  let result = @cpio.stream_entries(input, format, entry => {
    match failure {
      Some(_) => ()
      None =>
        match @cpio.validate_archive_path(entry.name) {
          Err(error) => failure = Some(UnsafePath(entry.name, error))
          Ok(_) => {
            let mode = entry_mode(entry.header)
            match mode & 0xF000UL {
              0x4000UL => count = count + 1
              0x8000UL =>
                if entry.name.has_suffix("/") {
                  failure = Some(FilePathHasTrailingSlash(entry.name))
                } else {
                  count = count + 1
                }
              _ => failure = Some(UnsupportedFileType(entry.name, mode))
            }
          }
        }
    }
  })
  match failure {
    Some(error) => return Err(error)
    None => ()
  }
  match result {
    Ok(_) => Ok(count)
    Err(error) => Err(Archive(error))
  }
}

///|
fn extract_entry(
  destination : String,
  entry : @cpio.StreamEntry,
) -> Result[Unit, CliError] {
  let target = match @cpio.archive_path_to_fs(destination, entry.name) {
    Ok(path) => path
    Err(error) => return Err(UnsafePath(entry.name, error))
  }
  let mode = entry_mode(entry.header)
  match mode & 0xF000UL {
    0x4000UL => {
      mkdir_all(target) catch {
        error => return Err(Io(@debug.to_string(error)))
      }
      Ok(())
    }
    0x8000UL => {
      let parent = parent_path(target)
      if !parent.is_empty() {
        mkdir_all(parent) catch {
          error => return Err(Io(@debug.to_string(error)))
        }
      }
      @fs.write_bytes_to_file(target, entry.data.to_owned()) catch {
        error => return Err(Io(@debug.to_string(error)))
      }
      Ok(())
    }
    _ => Err(UnsupportedFileType(entry.name, mode))
  }
}

///|
fn extract_archive(
  archive_path : String,
  destination : String,
  format_name_value : String,
) -> Result[Unit, CliError] {
  let input = match read_archive(archive_path) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let format = match resolve_format(input, format_name_value) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let total = match preflight_extract(input, format, destination) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let parent = parent_path(destination)
  if !parent.is_empty() && !@fs.path_exists(parent) {
    return Err(DestinationParentMissing(parent))
  }
  mkdir_all(destination) catch {
    error => return Err(Io(@debug.to_string(error)))
  }
  let mut extracted = 0
  let mut failure : CliError? = None
  let result = @cpio.stream_entries(input, format, entry => {
    match failure {
      Some(_) => ()
      None =>
        match extract_entry(destination, entry) {
          Ok(_) => extracted = extracted + 1
          Err(error) => failure = Some(error)
        }
    }
  })
  match failure {
    Some(error) => return Err(error)
    None => ()
  }
  match result {
    Ok(_) => {
      println("Extracted \{extracted}/\{total} entries to \{destination}")
      Ok(())
    }
    Err(error) => Err(Archive(error))
  }
}

///|
fn cli_error_message(error : CliError) -> String {
  match error {
    MissingArgument(name) => "missing required argument: '\{name}'"
    InvalidFormat(name) =>
      "invalid format '\{name}'; use auto, newc, odc, binary-old, binary-new-le, or binary-new-be"
    UnknownMagic => "unrecognized cpio archive magic"
    AmbiguousBinary =>
      "binary cpio detected; specify --format binary-old, binary-new-le, or binary-new-be"
    Io(message) => "filesystem error: \{message}"
    Archive(message) => "archive error: \{@debug.to_string(message)}"
    UnsafePath(path, reason) =>
      "unsafe archive path '\{path}': \{@debug.to_string(reason)}"
    FilePathHasTrailingSlash(path) =>
      "regular file entry has a trailing slash: '\{path}'"
    UnsupportedFileType(path, mode) =>
      "unsupported file type for '\{path}' (mode \{mode.to_string(radix=8)})"
    DestinationExists(path) =>
      "destination already exists; choose a new directory: '\{path}'"
    DestinationParentMissing(path) =>
      "destination parent directory does not exist: '\{path}'"
  }
}

///|
fn run_cli(matches : @argparse.Matches) -> Result[Unit, CliError] {
  let (subcommand, submatches) = match matches.subcommand {
    Some(value) => value
    None => return Err(InvalidFormat("missing subcommand"))
  }
  let format_value = first_value(submatches, "format")
  let archive_path = first_value(submatches, "archive")
  if archive_path.is_empty() {
    return Err(MissingArgument("archive"))
  }
  match subcommand {
    "list" => list_archive(archive_path, format_value)
    "validate" => validate_archive(archive_path, format_value)
    "extract" => {
      let destination = first_value(submatches, "destination")
      if destination.is_empty() {
        return Err(MissingArgument("destination"))
      }
      extract_archive(archive_path, destination, format_value)
    }
    _ => Err(InvalidFormat(subcommand))
  }
}

///|
fn main {
  let matches = cli_command().parse(env=Map([])) catch {
    error => {
      let message = error.to_string()
      println(message)
      if message.has_prefix("error:") {
        abort("")
      }
      return
    }
  }
  match run_cli(matches) {
    Ok(_) => ()
    Err(error) => {
      println("error: \{cli_error_message(error)}")
      abort("")
    }
  }
}