///|
/// An ISO 8601 timestamp, kept as the raw string for lossless round-tripping.
/// Discord sends e.g. `2021-03-11T18:15:37.907000+00:00`.
pub(all) struct Timestamp(String) derive(Eq)

///|
pub fn Timestamp::to_string(self : Timestamp) -> String {
  self.0
}

///|
pub impl Debug for Timestamp with fn to_repr(self) {
  Repr(self.0)
}

///|
pub impl ToJson for Timestamp with fn to_json(self) {
  Json::string(self.0)
}

///|
pub impl @json.FromJson for Timestamp with fn from_json(json, path) {
  match json {
    String(s) => Timestamp(s)
    _ => raise JsonDecodeError((path, "expected an ISO8601 timestamp"))
  }
}

///|
/// Raised when a string is not a valid ISO 8601 timestamp.
pub suberror TimestampError {
  InvalidTimestamp(String)
} derive(Debug)

///|
/// Parse the timestamp into milliseconds since the Unix epoch.
///
/// Supports the shapes Discord emits: `YYYY-MM-DDTHH:MM:SS[.f...](Z|±HH:MM)`.
///
/// ```mbt check
/// test "parse a Discord timestamp without losing its source text" {
///   let timestamp = @model.Timestamp("2021-03-11T18:15:37.907000+00:00")
///   inspect(timestamp.to_string(), content="2021-03-11T18:15:37.907000+00:00")
///   inspect(timestamp.unix_ms(), content="1615486537907")
/// }
/// ```
pub fn Timestamp::unix_ms(self : Timestamp) -> Int64 raise TimestampError {
  let s = self.0
  let len = s.length()
  fn digit(i : Int) -> Int raise TimestampError {
    guard i < len && s[i] >= '0' && s[i] <= '9' else {
      raise InvalidTimestamp(s)
    }
    s[i].to_int() - 48
  }

  fn two(i : Int) -> Int raise TimestampError {
    digit(i) * 10 + digit(i + 1)
  }

  fn sep(i : Int, c : Char) -> Unit raise TimestampError {
    guard i < len && s[i].to_int() == c.to_int() else {
      raise InvalidTimestamp(s)
    }
  }

  // YYYY-MM-DDTHH:MM:SS
  guard len >= 19 else { raise InvalidTimestamp(s) }
  let year = digit(0) * 1000 + digit(1) * 100 + digit(2) * 10 + digit(3)
  sep(4, '-')
  let month = two(5)
  sep(7, '-')
  let day = two(8)
  sep(10, 'T')
  let hour = two(11)
  sep(13, ':')
  let minute = two(14)
  sep(16, ':')
  let second = two(17)

  // optional fractional seconds, scaled to milliseconds
  let mut pos = 19
  let mut millis = 0
  if pos < len && s[pos] == '.' {
    pos += 1
    let frac_start = pos
    let mut scale = 100
    while pos < len && s[pos] >= '0' && s[pos] <= '9' {
      if scale > 0 {
        millis += digit(pos) * scale
        scale /= 10
      }
      pos += 1
    }
    guard pos > frac_start else { raise InvalidTimestamp(s) }
  }

  // timezone: Z, ±HH:MM, or absent (treated as UTC)
  let offset_minutes = if pos == len {
    0
  } else if s[pos] == 'Z' || s[pos] == 'z' {
    guard pos + 1 == len else { raise InvalidTimestamp(s) }
    0
  } else if s[pos] == '+' || s[pos] == '-' {
    guard pos + 6 == len else { raise InvalidTimestamp(s) }
    sep(pos + 3, ':')
    let total = two(pos + 1) * 60 + two(pos + 4)
    if s[pos] == '-' {
      -total
    } else {
      total
    }
  } else {
    raise InvalidTimestamp(s)
  }

  // days from civil (Howard Hinnant's algorithm), all in Int
  let y = if month <= 2 { year - 1 } else { year }
  let era = (if y >= 0 { y } else { y - 399 }) / 400
  let yoe = y - era * 400
  let mp = (month + 9) % 12
  let doy = (153 * mp + 2) / 5 + day - 1
  let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
  let days = era * 146097 + doe - 719468
  (
    (days.to_int64() * 24L + hour.to_int64()) * 60L +
    minute.to_int64() -
    offset_minutes.to_int64()
  ) *
  60000L +
  second.to_int64() * 1000L +
  millis.to_int64()
}

///|
/// A guild/user avatar or icon hash, kept raw (e.g. `a_1269e74af4df7417b13759eae50c83dc`).
pub(all) struct ImageHash(String) derive(Eq)

///|
pub fn ImageHash::to_string(self : ImageHash) -> String {
  self.0
}

///|
/// Animated assets (GIF avatars etc.) are prefixed with `a_`.
pub fn ImageHash::is_animated(self : ImageHash) -> Bool {
  self.0 is [.. "a_", ..]
}

///|
pub impl Debug for ImageHash with fn to_repr(self) {
  Repr(self.0)
}

///|
pub impl ToJson for ImageHash with fn to_json(self) {
  Json::string(self.0)
}

///|
pub impl @json.FromJson for ImageHash with fn from_json(json, path) {
  match json {
    String(s) => ImageHash(s)
    _ => raise JsonDecodeError((path, "expected an image hash"))
  }
}