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

///|
fn pad(value : Int, width : Int) -> String {
  let text = value.to_string()
  if text.length() >= width {
    text
  } else if value < 0 {
    "-" + "0".repeat(width - text.length()) + text[1:].to_owned()
  } 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,
  zone : @time_zone.Zone,
) -> @time.ZonedDateTime raise {
  if value.has_prefix("@") {
    let text = value[1:].to_owned()
    let parts = text.split(".").map(p => p.to_owned()).collect()
    guard parts.length() <= 2 else {
      raise DateError("invalid date '\{value}'")
    }
    let mut seconds = @string.parse_int64(parts[0])
    let mut nanos = 0
    if parts.length() == 2 {
      let fraction = parts[1]
      guard fraction.length() >= 1 &&
        fraction.length() <= 9 &&
        fraction.iter().all(c => c is ('0'..='9')) else {
        raise DateError("invalid fractional timestamp")
      }
      nanos = @string.parse_int(fraction + "0".repeat(9 - fraction.length()))
      if text.has_prefix("-") && nanos > 0 {
        guard seconds > -9223372036854775808L else {
          raise DateError("timestamp out of range")
        }
        seconds -= 1L
        nanos = 1000000000 - nanos
      }
    }
    zone.at_unix(seconds, nanosecond=nanos)
  } else {
    let normalized = value.replace(old=" ", new="T")
    let normalized = if normalized.contains("T") {
      normalized
    } else {
      normalized + "T00:00:00"
    }
    let plain : @time.PlainDateTime = @string.from_str(normalized) catch {
      _ => raise DateError("invalid date '\{value}'")
    }
    zone.at_local(plain)
  }
}

///|
fn format_date(
  value : @time.ZonedDateTime,
  format : String,
  abbreviation? : String,
) -> String raise DateError {
  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).abs(), 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' =>
        "\{pad(value.year(), 4)}-\{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' => abbreviation.unwrap_or(value.offset().abbreviation())
      'C'
      | 'D'
      | 'c'
      | 'x'
      | 'X'
      | 'I'
      | 'k'
      | 'l'
      | 'p'
      | 'P'
      | 'r'
      | 'n'
      | 't'
      | 'u'
      | 'w'
      | 'U'
      | 'W'
      | 'V'
      | 'g'
      | 'G'
      | 'q'
      | ':'
      | '-'
      | '_'
      | '^'
      | '#'
      | '+'
      | '0'..='9'
      | 'E'
      | 'O' =>
        raise DateError(
          "unsupported format directive or modifier: %" + code.to_string(),
        )
      _ => "%" + 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,
  instant : @time.ZonedDateTime,
) -> String raise DateError {
  let compact = compact_offset(instant.offset())
  let offset = compact[:3].to_owned() + ":" + compact[3:].to_owned()
  match value {
    "date" | "" => "%Y-%m-%d"
    "hours" => "%Y-%m-%dT%H" + offset
    "minutes" => "%Y-%m-%dT%H:%M" + offset
    "seconds" => "%Y-%m-%dT%H:%M:%S" + offset
    "ns" => "%Y-%m-%dT%H:%M:%S,%N" + offset
    _ => raise DateError("invalid ISO-8601 precision '\{value}'")
  }
}

///|
async fn main {
  let metadata_args = @env.args()[1:]
  if metadata_args.length() == 1 && metadata_args[0] == "--version" {
    @stdio.stdout.write("date 0.1.2\n")
    return
  }
  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 time in TZ or the host local timezone.\n  -u, --utc           use UTC\n  -d, --date=TEXT     ISO date/time or @SECONDS (up to 9 fractional digits)\n  -I[PRECISION], --iso-8601[=PRECISION]\n                      date, hours, minutes, seconds, or ns\n  -R, --rfc-email     RFC email timestamp\n      --help          display this help and exit\n      --version       display version and exit\nFormats: %Y %y %m %d %e %H %M %S %j %a %A %b %h %B %F %T %R %s %N %z %Z %%\nRelative date parsing and setting the clock are unsupported.\n",
    )
    return
  }
  if parsed.contains("version") {
    @stdio.stdout.write("date \{command_version}\n")
    return
  }
  if parsed.contains("rfc-email") && parsed.contains("iso") {
    @stdio.stderr.write("date: conflicting output formats\n")
    @sys.exit(1)
    return
  }
  if parsed.operands.length() > 1 ||
    parsed.operands.any(arg => !arg.has_prefix("+")) ||
    (
      !parsed.operands.is_empty() &&
      (parsed.contains("rfc-email") || parsed.contains("iso"))
    ) {
    @stdio.stderr.write("date: extra operand or conflicting output formats\n")
    @sys.exit(1)
    return
  }
  let (instant, abbreviation) = try {
    let zone = @time_zone.load(utc=parsed.contains("utc"))
    let value = match parsed.last_value("date") {
      Some(value) => parse_instant(value, zone)
      None => {
        let milliseconds = @env.now().reinterpret_as_int64()
        zone.at_unix(
          milliseconds / 1000L,
          nanosecond=(milliseconds % 1000L).to_int() * 1000000,
        )
      }
    }
    (value, zone.abbreviation(value.to_unix_second()))
  } catch {
    DateError(message) => {
      @stdio.stderr.write("date: \{message}\n")
      @sys.exit(1)
      return
    }
    err => {
      @stdio.stderr.write("date: \{err}\n")
      @sys.exit(1)
      return
    }
  }
  let format = if parsed.contains("rfc-email") {
    "%a, %d %b %Y %H:%M:%S %z"
  } else if parsed.contains("iso") {
    iso_format(parsed.last_value("iso").unwrap_or("date"), instant) 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 %Z %Y"
  } else {
    @stdio.stderr.write("date: extra operand\n")
    @sys.exit(1)
    return
  }
  let rendered = format_date(instant, format, abbreviation~) catch {
    DateError(message) => {
      @stdio.stderr.write("date: \{message}\n")
      @sys.exit(1)
      return
    }
  }
  @stdio.stdout.write(rendered + "\n")
}