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

///|
fn pad(value : Int, width : Int) -> String {
  let text = value.to_string()
  if text.length() >= width {
    text
  } else {
    "0".repeat(width - text.length()) + text
  }
}

///|
fn weekday_short(value : @time.Weekday) -> String {
  match value {
    Monday => "Mon"
    Tuesday => "Tue"
    Wednesday => "Wed"
    Thursday => "Thu"
    Friday => "Fri"
    Saturday => "Sat"
    Sunday => "Sun"
  }
}

///|
fn weekday_long(value : @time.Weekday) -> String {
  match value {
    Monday => "Monday"
    Tuesday => "Tuesday"
    Wednesday => "Wednesday"
    Thursday => "Thursday"
    Friday => "Friday"
    Saturday => "Saturday"
    Sunday => "Sunday"
  }
}

///|
fn month_short(month : Int) -> String {
  [
    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
    "Dec",
  ][month - 1]
}

///|
fn month_long(month : Int) -> String {
  [
    "January", "February", "March", "April", "May", "June", "July", "August", "September",
    "October", "November", "December",
  ][month - 1]
}

///|
fn compact_offset(offset : @time.ZoneOffset) -> String {
  let seconds = offset.seconds()
  let sign = if seconds < 0 { "-" } else { "+" }
  let absolute = if seconds < 0 { -seconds } else { seconds }
  sign + pad(absolute / 3600, 2) + pad(absolute / 60 % 60, 2)
}

///|
fn parse_instant(value : String) -> @time.ZonedDateTime raise DateError {
  if value.has_prefix("@") {
    let seconds = @string.parse_int64(value[1:].to_owned()) catch {
      _ => raise DateError("invalid date '\{value}'")
    }
    @time.unix(seconds, zone=@time.utc_zone) catch {
      _ => raise DateError("invalid date '\{value}'")
    }
  } else {
    let normalized = value.replace(old=" ", new="T")
    let plain : @time.PlainDateTime = @string.from_str(normalized) catch {
      _ => raise DateError("invalid date '\{value}'")
    }
    @time.ZonedDateTime::from_plain_datetime(plain, zone=@time.utc_zone)
  }
}

///|
fn format_date(value : @time.ZonedDateTime, format : String) -> String {
  let chars : Array[Char] = format.iter().collect()
  let output = StringBuilder()
  let mut index = 0
  while index < chars.length() {
    if chars[index] != '%' || index + 1 >= chars.length() {
      output.write_char(chars[index])
      index += 1
      continue
    }
    let code = chars[index + 1]
    let rendered = match code {
      '%' => "%"
      'Y' => pad(value.year(), 4)
      'y' => pad(value.year() % 100, 2)
      'm' => pad(value.month(), 2)
      'd' => pad(value.day(), 2)
      'e' =>
        if value.day() < 10 {
          " \{value.day()}"
        } else {
          value.day().to_string()
        }
      'H' => pad(value.hour(), 2)
      'M' => pad(value.minute(), 2)
      'S' => pad(value.second(), 2)
      'j' => pad(value.ordinal(), 3)
      'a' => weekday_short(value.weekday())
      'A' => weekday_long(value.weekday())
      'b' | 'h' => month_short(value.month())
      'B' => month_long(value.month())
      'F' => "\{value.year()}-\{pad(value.month(), 2)}-\{pad(value.day(), 2)}"
      'T' =>
        "\{pad(value.hour(), 2)}:\{pad(value.minute(), 2)}:\{pad(value.second(), 2)}"
      'R' => "\{pad(value.hour(), 2)}:\{pad(value.minute(), 2)}"
      's' => value.to_unix_second().to_string()
      'N' => pad(value.nanosecond(), 9)
      'z' => compact_offset(value.offset())
      'Z' => "UTC"
      _ => "%" + code.to_string()
    }
    output.write_string(rendered)
    index += 2
  }
  output.to_string()
}

///|
fn normalize_args(raw : ArrayView[String]) -> Array[String] {
  let args : Array[String] = []
  for arg in raw {
    if arg == "--iso-8601" {
      args.push("--iso=date")
    } else if arg.has_prefix("--iso-8601=") {
      args.push("--iso=" + arg[11:].to_owned())
    } else if arg == "-I" {
      args.push("--iso=date")
    } else if arg.has_prefix("-I") && arg.length() > 2 {
      args.push("--iso=" + arg[2:].to_owned())
    } else {
      args.push(arg)
    }
  }
  args
}

///|
fn iso_format(value : String) -> String raise DateError {
  match value {
    "date" | "" => "%Y-%m-%d"
    "hours" => "%Y-%m-%dT%H+00:00"
    "minutes" => "%Y-%m-%dT%H:%M+00:00"
    "seconds" => "%Y-%m-%dT%H:%M:%S+00:00"
    "ns" => "%Y-%m-%dT%H:%M:%S.%N+00:00"
    _ => raise DateError("invalid ISO-8601 precision '\{value}'")
  }
}

///|
async fn main {
  let parsed = @cli.parse(normalize_args(@env.args()[1:]), [
    @cli.flag("utc", short='u'),
    @cli.option("date", short='d'),
    @cli.option("iso"),
    @cli.flag("rfc-email", short='R'),
    @cli.flag("help"),
    @cli.flag("version"),
  ]) catch {
    @cli.CliError(option~, message~, ..) => {
      @stdio.stderr.write("date: \{message}: '\{option}'\n")
      @sys.exit(1)
      return
    }
  }
  if parsed.contains("help") {
    @stdio.stdout.write(
      "Usage: date [OPTION]... [+FORMAT]\nPrint a UTC timestamp; -d accepts ISO-like text or @SECONDS.\n",
    )
    return
  }
  if parsed.contains("version") {
    @stdio.stdout.write("date (cli) \{command_version}\n")
    return
  }
  if parsed.contains("rfc-email") && parsed.contains("iso") {
    @stdio.stderr.write("date: conflicting output formats\n")
    @sys.exit(1)
    return
  }
  let instant = match parsed.last_value("date") {
    Some(value) =>
      parse_instant(value) catch {
        DateError(message) => {
          @stdio.stderr.write("date: \{message}\n")
          @sys.exit(1)
          return
        }
      }
    None =>
      @time.unix(@env.now().reinterpret_as_int64() / 1000L, zone=@time.utc_zone)
  }
  let format = if parsed.contains("rfc-email") {
    "%a, %d %b %Y %H:%M:%S +0000"
  } else if parsed.contains("iso") {
    iso_format(parsed.last_value("iso").unwrap_or("date")) catch {
      DateError(message) => {
        @stdio.stderr.write("date: \{message}\n")
        @sys.exit(1)
        return
      }
    }
  } else if parsed.operands.length() == 1 && parsed.operands[0].has_prefix("+") {
    parsed.operands[0][1:].to_owned()
  } else if parsed.operands.is_empty() {
    "%a %b %e %H:%M:%S UTC %Y"
  } else {
    @stdio.stderr.write("date: extra operand\n")
    @sys.exit(1)
    return
  }
  @stdio.stdout.write(format_date(instant, format) + "\n")
}