///|
priv suberror CliError {
  CliError(String)
}

///|
fn xxd_command() -> @argparse.Command {
  Command(
    "xxd",
    about="Make a hex dump, or reverse one back into bytes.",
    flags=[
      FlagArg("plain", short='p', about="Plain continuous hex dump."),
      FlagArg("reverse", short='r', about="Convert a hex dump back into bytes."),
    ],
    options=[
      OptionArg(
        "cols",
        short='c',
        about="Bytes per line (default 16, or 30 with -p).",
      ),
      OptionArg("len", short='l', about="Stop after N input bytes."),
    ],
    positionals=[
      PositionArg(
        "input",
        about="[file] ('-' or no file reads stdin)",
        num_args=ValueRange(lower=0, upper=1),
      ),
    ],
    disable_help_subcommand=true,
  )
}

///|
fn hex_digit(n : Int) -> Char {
  if n < 10 {
    (0x30 + n).unsafe_to_char()
  } else {
    (0x61 + n - 10).unsafe_to_char()
  }
}

///|
fn write_hex_byte(sb : StringBuilder, b : Byte) -> Unit {
  sb.write_char(hex_digit(b.to_int() >> 4))
  sb.write_char(hex_digit(b.to_int() & 0xF))
}

///|
fn hex_val(c : Char) -> Int {
  match c {
    '0'..='9' => c.to_int() - 0x30
    'a'..='f' => c.to_int() - 0x61 + 10
    'A'..='F' => c.to_int() - 0x41 + 10
    _ => -1
  }
}

///|
fn dump(data : BytesView, cols : Int) -> String {
  let sb = StringBuilder()
  let mut offset = 0
  while offset < data.length() {
    for k in 0..<8 {
      sb.write_char(hex_digit((offset >> ((7 - k) * 4)) & 0xF))
    }
    sb.write_string(": ")
    let line_end = if offset + cols < data.length() {
      offset + cols
    } else {
      data.length()
    }
    for i in 0.. 0 && i % 2 == 0 {
        sb.write_char(' ')
      }
      if offset + i < line_end {
        write_hex_byte(sb, data[offset + i])
      } else {
        sb.write_string("  ")
      }
    }
    sb.write_string("  ")
    for i in offset..= 0x20 && code <= 0x7E {
        sb.write_char(code.unsafe_to_char())
      } else {
        sb.write_char('.')
      }
    }
    sb.write_char('\n')
    offset += cols
  }
  sb.to_string()
}

///|
fn dump_plain(data : BytesView, cols : Int) -> String {
  let sb = StringBuilder()
  for index, b in data {
    write_hex_byte(sb, b)
    if (index + 1) % cols == 0 {
      sb.write_char('\n')
    }
  }
  if data.length() > 0 && data.length() % cols != 0 {
    sb.write_char('\n')
  }
  sb.to_string()
}

///|
fn reverse_plain(text : String) -> Bytes raise CliError {
  let out : Array[Byte] = []
  let mut high = -1
  for c in text {
    if c is (' ' | '\t' | '\n' | '\r') {
      continue
    }
    let v = hex_val(c)
    if v < 0 {
      raise CliError("xxd: invalid hex character: '\{c}'")
    }
    if high < 0 {
      high = v
    } else {
      out.push(((high << 4) | v).to_byte())
      high = -1
    }
  }
  if high >= 0 {
    raise CliError("xxd: odd number of hex digits")
  }
  Bytes::from_array(out)
}

///|
fn split_lines(text : String) -> Array[String] {
  if text is "" {
    return []
  }
  let lines : Array[String] = text.split("\n").map(v => v.to_owned()).collect()
  if text.has_suffix("\n") {
    ignore(lines.pop())
  }
  lines
}

///|
/// Parse hex byte pairs from the region after the "offset:" column of a
/// standard xxd dump line. Hex groups are separated by single spaces; the
/// ASCII column is separated by at least two spaces, which ends the scan.
fn reverse_dump(text : String) -> Bytes {
  let out : Array[Byte] = []
  for line in split_lines(text) {
    let chars : Array[Char] = line.iter().collect()
    let mut i = 0
    while i < chars.length() && chars[i] != ':' {
      i += 1
    }
    if i >= chars.length() {
      continue
    }
    i += 1
    while i < chars.length() {
      if chars[i] == ' ' {
        if i + 1 < chars.length() && chars[i + 1] == ' ' {
          break
        }
        i += 1
        continue
      }
      if i + 1 >= chars.length() {
        break
      }
      let high = hex_val(chars[i])
      let low = hex_val(chars[i + 1])
      if high < 0 || low < 0 {
        break
      }
      out.push(((high << 4) | low).to_byte())
      i += 2
    }
  }
  Bytes::from_array(out)
}

///|
fn option_value(matches : @argparse.Matches, name : String) -> String? {
  match matches.values.get(name) {
    Some(vals) =>
      if vals.is_empty() {
        None
      } else {
        Some(vals[vals.length() - 1])
      }
    None => None
  }
}

///|
fn parse_number(
  matches : @argparse.Matches,
  name : String,
  minimum? : Int = 1,
) -> Int? raise CliError {
  match option_value(matches, name) {
    Some(text) => {
      let n = @string.parse_int(text) catch {
        _ => raise CliError("xxd: invalid -\{name} value: '\{text}'")
      }
      if n < minimum {
        raise CliError("xxd: invalid -\{name} value: '\{text}'")
      }
      Some(n)
    }
    None => None
  }
}

///|
async fn read_source_bytes(path : String) -> Bytes {
  if path == "-" {
    @stdio.stdin.read_all().binary()
  } else {
    @fs.read_file_to_bytes(path)
  }
}

///|
async fn read_source_text(path : String) -> String {
  if path == "-" {
    @stdio.stdin.read_all().text()
  } else {
    @fs.read_file_to_string(path)
  }
}

///|
async fn main {
  let args = @env.args()[1:]
  let command = xxd_command()
  let matches = command.parse(argv=args, env=Map([])) catch {
    err => {
      @stdio.stderr.write("\{err}\n")
      @sys.exit(2)
      return
    }
  }
  let plain = matches.flags.get_or_default("plain", false)
  let reverse = matches.flags.get_or_default("reverse", false)
  let (cols_opt, len_opt) = (
    parse_number(matches, "cols"),
    parse_number(matches, "len", minimum=0),
  ) catch {
    CliError(msg) => {
      @stdio.stderr.write("\{msg}\n")
      @sys.exit(2)
      return
    }
  }
  let cols = match cols_opt {
    Some(n) => n
    None => if plain { 30 } else { 16 }
  }
  let inputs = matches.values.get("input").unwrap_or([])
  let path = if inputs.is_empty() { "-" } else { inputs[0] }
  try {
    if reverse {
      let text = read_source_text(path)
      let bytes = if plain { reverse_plain(text) } else { reverse_dump(text) }
      @stdio.stdout.write(bytes)
    } else {
      let data = read_source_bytes(path)
      let limit = match len_opt {
        Some(n) => if n < data.length() { n } else { data.length() }
        None => data.length()
      }
      let view = data[0:limit]
      let rendered = if plain {
        dump_plain(view, cols)
      } else {
        dump(view, cols)
      }
      @stdio.stdout.write(rendered)
    }
  } catch {
    CliError(msg) => {
      @stdio.stderr.write("\{msg}\n")
      @sys.exit(1)
      return
    }
    err => {
      @stdio.stderr.write("xxd: \{err}\n")
      @sys.exit(1)
      return
    }
  }
}