// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// How an instant is written.
///
/// The library holds no clock, so every instant here arrives as nanoseconds
/// since the epoch and every local time is the instant shifted by an offset
/// that was handed in. A `Custom` spec is strftime, the subset below.
pub(all) enum Clock {
  Local
  Utc
  Rfc3339
  Unix
  UnixMs
  UnixNs
  Custom(String)
} derive(Eq, Debug)

///|
pub extend Clock with Debug::{to_repr}

///|
pub extend Clock with Eq::{equal, not_equal}

///|
/// A clock spec, as a `{time:…}` hole spells it.
pub fn Clock::parse(spec : StringView) -> Clock {
  match spec {
    "" | "local" => Local
    "utc" => Utc
    "rfc3339" => Rfc3339
    "unix" => Unix
    "unix_ms" => UnixMs
    "unix_ns" => UnixNs
    _ => Custom(spec.to_owned())
  }
}

///|
/// Writes `time` in this clock's form; `offset` is seconds east of UTC.
pub fn Clock::write(
  self : Clock,
  out : StringBuilder,
  time : Int64,
  offset : Int32,
) -> Unit {
  match self {
    Local => write_stamp(out, time, offset, 3)
    Utc => write_utc(out, time)
    Rfc3339 => write_offset_stamp(out, time, offset, 6, sep="T")
    Unix => out.write_string(floor_div(time, 1_000_000_000L).to_string())
    UnixMs => out.write_string(floor_div(time, 1_000_000L).to_string())
    UnixNs => out.write_string(time.to_string())
    Custom(spec) => write_strftime(out, time, offset, spec)
  }
}

///|
/// `2023-11-14 23:13:20.000`: what a default line puts in front.
fn write_stamp(
  out : StringBuilder,
  time : Int64,
  offset : Int32,
  digits : Int32,
) -> Unit {
  write_parts(out, time, offset, digits, sep=" ", zone=false)
}

///|
/// `2023-11-14T22:13:20Z`: whole seconds in UTC, as `{time:utc}` writes.
fn write_utc(out : StringBuilder, time : Int64) -> Unit {
  write_parts(out, time, 0, 0, sep="T", zone=false)
  out.write_char('Z')
}

///|
/// `2023-11-14T22:13:20.000Z`: milliseconds, which is what JSON schemas want.
fn write_iso(out : StringBuilder, time : Int64) -> Unit {
  write_parts(out, time, 0, 3, sep="T", zone=false)
  out.write_char('Z')
}

///|
/// `2023-11-14T22:13:20.000000000Z`: nanoseconds, as the OpenTelemetry model
/// writes a timestamp.
fn write_iso_nanos(out : StringBuilder, time : Int64) -> Unit {
  write_parts(out, time, 0, 9, sep="T", zone=false)
  out.write_char('Z')
}

///|
/// `2023-11-14T23:13:20.000000+01:00`: local time with its offset.
fn write_offset_stamp(
  out : StringBuilder,
  time : Int64,
  offset : Int32,
  digits : Int32,
  sep~ : String = "T",
) -> Unit {
  write_parts(out, time, offset, digits, sep~, zone=true)
}

///|
/// The date and time at `offset`, optionally followed by the offset itself.
fn write_parts(
  out : StringBuilder,
  time : Int64,
  offset : Int32,
  digits : Int32,
  sep~ : String,
  zone~ : Bool,
) -> Unit {
  let shifted = time + offset.to_int64() * 1_000_000_000L
  let (year, month, day, hour, minute, second, nanos) = broken_down(shifted)
  write_digits(out, year, 4)
  out.write_char('-')
  write_digits(out, month, 2)
  out.write_char('-')
  write_digits(out, day, 2)
  out.write_string(sep)
  write_digits(out, hour, 2)
  out.write_char(':')
  write_digits(out, minute, 2)
  out.write_char(':')
  write_digits(out, second, 2)
  if digits > 0 {
    let scale = pow10(9 - digits)
    let frac = nanos / scale.to_int64()
    out.write_char('.')
    write_digits(out, frac.to_int(), digits)
  }
  if zone {
    write_zone(out, offset)
  }
}

///|
/// `+01:00`, or `+0000` when a strftime `%z` asks for the compact form.
fn write_zone(out : StringBuilder, offset : Int32) -> Unit {
  let sign = if offset < 0 { '-' } else { '+' }
  let abs = if offset < 0 { -offset } else { offset }
  let hours = abs / 3600
  let minutes = abs / 60 % 60
  out.write_char(sign)
  write_digits(out, hours, 2)
  out.write_char(':')
  write_digits(out, minutes, 2)
}

///|
/// Writes `n` with at least `width` digits, zero-padded.
fn write_digits(out : StringBuilder, n : Int32, width : Int32) -> Unit {
  let text = n.to_string()
  let mut pad = width - text.length()
  while pad > 0 {
    out.write_char('0')
    pad = pad - 1
  }
  out.write_string(text)
}

///|
/// The instant split into civil parts, UTC.
fn broken_down(
  time : Int64,
) -> (Int32, Int32, Int32, Int32, Int32, Int32, Int64) {
  let day = floor_div(time, 86_400_000_000_000L)
  let rest = time - day * 86_400_000_000_000L
  let (year, month, date) = civil_from_days(day.to_int())
  let hour = (rest / 3_600_000_000_000L).to_int()
  let minute = (rest / 60_000_000_000L % 60L).to_int()
  let second = (rest / 1_000_000_000L % 60L).to_int()
  let nanos = rest % 1_000_000_000L
  (year, month, date, hour, minute, second, nanos)
}

///|
/// Days since the epoch to a civil date, by Hinnant's `civil_from_days`.
fn civil_from_days(days : Int32) -> (Int32, Int32, Int32) {
  let z = days + 719468
  let era = if z >= 0 { z / 146097 } else { (z - 146096) / 146097 }
  let doe = z - era * 146097
  let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
  let y = yoe + era * 400
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
  let mp = (5 * doy + 2) / 153
  let d = doy - (153 * mp + 2) / 5 + 1
  let m = if mp < 10 { mp + 3 } else { mp - 9 }
  let year = if m <= 2 { y + 1 } else { y }
  (year, m, d)
}

///|
/// Division that rounds towards negative infinity, so instants before 1970
/// land on the day they belong to.
fn floor_div(a : Int64, b : Int64) -> Int64 {
  let q = a / b
  if a % b != 0L && (a < 0L) != (b < 0L) {
    q - 1L
  } else {
    q
  }
}

///|
fn pow10(n : Int32) -> Int32 {
  let mut out = 1
  for _ in 0.. Unit {
  let shifted = time + offset.to_int64() * 1_000_000_000L
  let (year, month, day, hour, minute, second, nanos) = broken_down(shifted)
  let chars = spec.to_array()
  let mut i = 0
  while i < chars.length() {
    let c = chars[i]
    if c != '%' {
      out.write_char(c)
      i = i + 1
      continue
    }
    i = i + 1
    if i >= chars.length() {
      out.write_char('%')
      break
    }
    let mut digits = -1
    if chars[i] == '.' {
      i = i + 1
      let mut n = 0
      while i < chars.length() && chars[i] >= '0' && chars[i] <= '9' {
        n = n * 10 + (chars[i].to_int() - 48)
        i = i + 1
      }
      digits = n
    }
    let mut colon = false
    if i < chars.length() && chars[i] == ':' {
      colon = true
      i = i + 1
    }
    if i >= chars.length() {
      out.write_char('%')
      break
    }
    match chars[i] {
      'Y' => write_digits(out, year, 4)
      'm' => write_digits(out, month, 2)
      'd' => write_digits(out, day, 2)
      'e' => {
        if day < 10 {
          out.write_char(' ')
        }
        out.write_string(day.to_string())
      }
      'H' => write_digits(out, hour, 2)
      'M' => write_digits(out, minute, 2)
      'S' => write_digits(out, second, 2)
      'f' => {
        let n = if digits >= 0 { digits } else { 9 }
        let scale = pow10(9 - n)
        out.write_char('.')
        write_digits(out, (nanos / scale.to_int64()).to_int(), n)
      }
      'z' =>
        if colon {
          write_zone(out, offset)
        } else {
          let sign = if offset < 0 { '-' } else { '+' }
          let abs = if offset < 0 { -offset } else { offset }
          out.write_char(sign)
          write_digits(out, abs / 3600, 2)
          write_digits(out, abs / 60 % 60, 2)
        }
      '%' => out.write_char('%')
      other => {
        out.write_char('%')
        out.write_char(other)
      }
    }
    i = i + 1
  }
}