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

// Go's reference-time layout language. A layout is a template written with the
// single moment Go chose as its reference — `Mon Jan 2 15:04:05 MST 2006` — where
// every recognisable piece of that reference names a field and everything else is
// a literal. [`Moment::layout`] writes a moment the way the template says;
// [`parse_layout`] reads it back by treating the template as a pattern.

///|
/// A token of a Go layout: either literal text or one of the reference-time fields.
priv enum GKind {
  GLit
  GYear4
  GYear2
  GMonLong
  GMonShort
  GMon2
  GMon1
  GDay2
  GDay1
  GDaySpace
  GDayYear3
  GWDShort
  GWDLong
  GH24
  GH12z
  GH12
  GMin2
  GMin1
  GSec2
  GSec1
  GFrac
  GTZAbbr
  GTZColon
  GTZSign
  GMeridiem
}

///|
priv struct GToken {
  kind : GKind
  text : String
}

///|
/// The predefined layouts Go's `time` package ships with, so a caller need not
/// spell the reference time out by hand.
pub const ANSIC : String = "Mon Jan _2 15:04:05 2006"

///|
pub const UnixDate : String = "Mon Jan _2 15:04:05 MST 2006"

///|
pub const RubyDate : String = "Mon Jan 02 15:04:05 -0700 2006"

///|
pub const RFC822 : String = "02 Jan 06 15:04 MST"

///|
pub const RFC822Z : String = "02 Jan 06 15:04 -0700"

///|
pub const RFC850 : String = "Monday, 02-Jan-06 15:04:05 MST"

///|
pub const RFC1123 : String = "Mon, 02 Jan 2006 15:04:05 MST"

///|
pub const RFC1123Z : String = "Mon, 02 Jan 2006 15:04:05 -0700"

///|
pub const RFC3339 : String = "2006-01-02T15:04:05Z07:00"

///|
pub const RFC3339Nano : String = "2006-01-02T15:04:05.999999999Z07:00"

///|
pub const Kitchen : String = "3:04PM"

///|
pub const Stamp : String = "Jan _2 15:04:05"

///|
pub const StampMilli : String = "Jan _2 15:04:05.000"

///|
pub const StampMicro : String = "Jan _2 15:04:05.000000"

///|
pub const StampNano : String = "Jan _2 15:04:05.000000000"

///|
pub const DateTime : String = "2006-01-02 15:04:05"

///|
pub const DateOnly : String = "2006-01-02"

///|
pub const TimeOnly : String = "15:04:05"

///|
/// A moment written by a Go reference-time layout.
///
/// `Moment::layout(RFC3339)` is `2006-01-02T15:04:05Z07:00` with the fields
/// filled in, which is the same text Go's `time.Format` would write for the same
/// instant. A moment with no zone writes its offset forms as if it sat at UTC, and
/// a bare time or date is padded with 1 January 1900 as `strftime` does.
pub fn Moment::layout(self : Moment, layout : StringView) -> String {
  let (d, t, z, _) = decompose(self)
  let off = match z {
    Some(Utc) => 0
    Some(Offset(m)) => m
    None => 0
  }
  let toks = scan_layout(layout.to_owned())
  let out = StringBuilder()
  for i = 0; i < toks.length(); i = i + 1 {
    out.write_string(render_token(toks[i], d, t, off))
  }
  out.to_string()
}

///|
/// A moment read by a Go reference-time layout.
///
/// The layout is the pattern the text must follow: each field token pulls its
/// value out, and literal characters — including a layout space, which swallows any
/// run of whitespace — must match. A layout that names an offset yields a `Zoned`
/// moment; one that does not yields `Plain`, `Day` or `Clock`, as [`parse`] does.
pub fn parse_layout(
  src : StringView,
  layout : StringView,
) -> Moment raise Refused {
  let s = src.to_owned()
  let toks = scan_layout(layout.to_owned())
  let mut year = 1900
  let mut month = 1
  let mut mday = 1
  let mut hour = 0
  let mut minute = 0
  let mut sec = 0
  let mut nano = 0
  let mut doy : Int32? = None
  let mut pm : Bool? = None
  let mut off : Int32? = None
  let mut full_year = false
  let mut saw_date = false
  let mut saw_time = false
  let mut j = 0
  for i = 0; i < toks.length(); i = i + 1 {
    let tok = toks[i]
    match tok.kind {
      GLit => j = match_lit(s, j, tok.text)
      GYear4 => {
        let (v, k) = take(s, j, 4)
        year = v
        full_year = true
        j = k
        saw_date = true
      }
      GYear2 => {
        let (v, k) = take(s, j, 2)
        year = if v < 69 { 2000 + v } else { 1900 + v }
        j = k
        saw_date = true
      }
      GMonLong | GMonShort => {
        let (m, k) = read_month(s, j)
        month = m
        j = k
        saw_date = true
      }
      GMon2 => {
        let (v, k) = take(s, j, 2)
        month = v
        j = k
        saw_date = true
      }
      GMon1 => {
        let (v, k) = take(s, j, 2)
        month = v
        j = k
        saw_date = true
      }
      GDay2 | GDay1 => {
        let (v, k) = take(s, j, 2)
        mday = v
        j = k
        saw_date = true
      }
      GDaySpace => {
        if j < s.length() && s[j].to_int() == 0x20 {
          j = j + 1
        }
        let (v, k) = take(s, j, 2)
        mday = v
        j = k
        saw_date = true
      }
      GDayYear3 => {
        let (v, k) = take(s, j, 3)
        doy = Some(v)
        j = k
      }
      GWDShort | GWDLong => j = skip_name(s, j)
      GH24 | GH12z | GH12 => {
        let (v, k) = take(s, j, 2)
        hour = v
        j = k
        saw_time = true
      }
      GMin2 | GMin1 => {
        let (v, k) = take(s, j, 2)
        minute = v
        j = k
        saw_time = true
      }
      GSec2 | GSec1 => {
        let (v, k) = take(s, j, 2)
        sec = v
        j = k
        saw_time = true
      }
      GFrac => {
        if j < s.length() && s[j].to_int() == 0x2e {
          j = j + 1
        } else {
          raise Malformed("expected a dot before the fraction at \{j}")
        }
        let p = tok.text.length() - 1
        let start = j
        let mut v = 0
        while j < s.length() && is_digit(s[j].to_int()) && j - start < p {
          v = v * 10 + (s[j].to_int() - 48)
          j = j + 1
        }
        if j == start {
          raise Malformed("expected digits for the fraction at \{start}")
        }
        for k = j - start; k < 9; k = k + 1 {
          v = v * 10
        }
        nano = v
      }
      GTZAbbr => j = skip_name(s, j)
      GTZColon => {
        let (o, k) = read_offset(s, j)
        off = Some(o)
        j = k
      }
      GTZSign => {
        let (o, k) = read_offset(s, j)
        off = Some(o)
        j = k
      }
      GMeridiem => {
        if j + 2 > s.length() {
          raise Truncated("expected AM, PM, am or pm")
        }
        let word = s[j:j + 2].to_owned().to_upper()
        pm = match word {
          "AM" => Some(false)
          "PM" => Some(true)
          _ => raise Malformed("expected AM/PM at \{j}")
        }
        j = j + 2
      }
    }
  }
  match pm {
    Some(true) => if hour < 12 { hour = hour + 12 }
    Some(false) => if hour == 12 { hour = 0 }
    None => ()
  }
  match (doy, full_year) {
    (Some(v), true) => {
      let base = { year, month: 1, day: 1, }
      let dd = base.plus_days(v - 1)
      year = dd.year
      month = dd.month
      mday = dd.day
      saw_date = true
    }
    _ => ()
  }
  let time = Time::new(hour, minute, second=sec, nano~)
  if !saw_date {
    return Clock(time~)
  }
  let date = Date::new(year, month, mday)
  if !saw_time {
    return Day(date~)
  }
  match off {
    Some(0) => Zoned(date~, time~, zone=Utc)
    Some(m) => Zoned(date~, time~, zone=Offset(m))
    None => Plain(date~, time~)
  }
}

///|
/// One field token turned into text for the moment it describes. `off` is the
/// offset in minutes, taken as zero when the moment names no zone.
fn render_token(tok : GToken, d : Date, t : Time, off : Int32) -> String {
  match tok.kind {
    GLit => tok.text
    GYear4 => year_field(d.year, 4)
    GYear2 => pad((d.year % 100 + 100) % 100, 2)
    GMonLong => long_months[d.month - 1]
    GMonShort => months[d.month - 1]
    GMon2 => pad(d.month, 2)
    GMon1 => d.month.to_string()
    GDay2 => pad(d.day, 2)
    GDay1 => d.day.to_string()
    GDaySpace =>
      if d.day < 10 {
        " " + d.day.to_string()
      } else {
        d.day.to_string()
      }
    GDayYear3 => pad(d.yearday(), 3)
    GWDShort => weekdays[d.weekday()]
    GWDLong => long_days[d.weekday()]
    GH24 => pad(t.hour, 2)
    GH12z => pad(hour12(t), 2)
    GH12 => hour12(t).to_string()
    GMin2 => pad(t.minute, 2)
    GMin1 => t.minute.to_string()
    GSec2 => pad(t.second, 2)
    GSec1 => t.second.to_string()
    GFrac => frac_text(t.nano, tok.text)
    GTZAbbr =>
      if off == 0 {
        "UTC"
      } else {
        // No zone database: the name a fixed offset carries here is `±HH:MM`.
        let sign = if off < 0 { "-" } else { "+" }
        let a = if off < 0 { -off } else { off }
        sign + pad(a / 60, 2) + ":" + pad(a % 60, 2)
      }
    GTZColon => if off == 0 { "Z" } else { offset_pattern(off, tok.text) }
    GTZSign => offset_pattern(off, tok.text)
    GMeridiem => {
      let upper = tok.text[0].to_int() == 0x50 || tok.text[0].to_int() == 0x41
      if t.hour >= 12 {
        if upper {
          "PM"
        } else {
          "pm"
        }
      } else if upper {
        "AM"
      } else {
        "am"
      }
    }
  }
}

///|
/// A year written with `width` digits, with a leading minus for a negative year.
fn year_field(year : Int32, width : Int32) -> String {
  if year < 0 {
    "-" + pad(-year, width)
  } else {
    pad(year, width)
  }
}

///|
/// The fractional second Go's `.999…` or `.000…` asks for.
fn frac_text(nano : Int32, text : String) -> String {
  let p = text.length() - 1
  let scale = power10(9 - p)
  let first = nano / scale
  let digits = pad(first, p)
  // A token of nines drops trailing zeros, and the whole fractional part when the
  // value is a whole second; a token of zeros always keeps its width.
  if text[1].to_int() == 0x30 {
    "." + digits
  } else {
    let mut s = digits
    while s.length() > 0 && s[s.length() - 1].to_int() == 48 {
      s = s[0:s.length() - 1].to_owned()
    }
    if s.length() == 0 {
      ""
    } else {
      "." + s
    }
  }
}

///|
/// An offset written the way a `Z` form or a sign form asks: `Z` for zero on a `Z`
/// form, otherwise the sign and the hours, minutes and seconds the pattern names.
fn offset_pattern(off : Int32, text : String) -> String {
  let sign = if off < 0 { "-" } else { "+" }
  let a = if off < 0 { -off } else { off }
  let hh = a / 60
  let mm = a % 60
  let ss = 0
  // The pattern after the leading `Z` or sign: digit groups with optional colons.
  let colons = count_char(text, 0x3a)
  let mut digits = 0
  for k = 0; k < text.length(); k = k + 1 {
    if is_digit(text[k].to_int()) {
      digits = digits + 1
    }
  }
  if colons == 0 {
    if digits <= 2 {
      sign + pad(hh, 2)
    } else if digits <= 4 {
      sign + pad(hh, 2) + pad(mm, 2)
    } else {
      sign + pad(hh, 2) + pad(mm, 2) + pad(ss, 2)
    }
  } else if colons == 1 {
    sign + pad(hh, 2) + ":" + pad(mm, 2)
  } else {
    sign + pad(hh, 2) + ":" + pad(mm, 2) + ":" + pad(ss, 2)
  }
}

///|
fn count_char(s : String, c : Int32) -> Int32 {
  let mut n = 0
  for i = 0; i < s.length(); i = i + 1 {
    if s[i].to_int() == c {
      n = n + 1
    }
  }
  n
}

///|
/// The layout split into tokens, longest field match first so `15` wins over `1`.
fn scan_layout(layout : String) -> Array[GToken] {
  let toks = []
  let mut i = 0
  while i < layout.length() {
    let (tok, next) = scan_token(layout, i)
    toks.push(tok)
    i = next
  }
  toks
}

///|
/// The token that starts at `i`, and where the layout continues after it.
fn scan_token(layout : String, i : Int32) -> (GToken, Int32) {
  if layout[i].to_int() == 0x2e {
    let mut k = i + 1
    while k < layout.length() {
      let c = layout[k].to_int()
      if c == 0x39 || c == 0x30 {
        k = k + 1
      } else {
        break
      }
    }
    if k > i + 1 {
      return ({ kind: GFrac, text: layout[i:k].to_owned(), }, k)
    }
  }
  let candidates : Array[(String, GKind)] = [
    ("Monday", GWDLong),
    ("January", GMonLong),
    ("Z07:00:00", GTZColon),
    ("Z070000", GTZColon),
    ("Z07:00", GTZColon),
    ("Z0700", GTZColon),
    ("Z07", GTZColon),
    ("-07:00:00", GTZSign),
    ("-070000", GTZSign),
    ("-07:00", GTZSign),
    ("-0700", GTZSign),
    ("-07", GTZSign),
    ("2006", GYear4),
    ("Mon", GWDShort),
    ("Jan", GMonShort),
    ("MST", GTZAbbr),
    ("002", GDayYear3),
    ("06", GYear2),
    ("01", GMon2),
    ("02", GDay2),
    ("15", GH24),
    ("03", GH12z),
    ("PM", GMeridiem),
    ("pm", GMeridiem),
    ("AM", GMeridiem),
    ("am", GMeridiem),
    ("_2", GDaySpace),
    ("04", GMin2),
    ("05", GSec2),
    ("1", GMon1),
    ("2", GDay1),
    ("3", GH12),
    ("4", GMin1),
    ("5", GSec1),
  ]
  for c = 0; c < candidates.length(); c = c + 1 {
    let (s, knd) = candidates[c]
    if starts_at(layout, i, s) {
      return ({ kind: knd, text: s, }, i + s.length())
    }
  }
  ({ kind: GLit, text: layout[i:i + 1].to_owned(), }, i + 1)
}

///|
fn starts_at(layout : String, i : Int32, s : String) -> Bool {
  if i + s.length() > layout.length() {
    return false
  }
  for k = 0; k < s.length(); k = k + 1 {
    if layout[i + k].to_int() != s[k].to_int() {
      return false
    }
  }
  true
}

///|
/// A literal run matched, with a single space swallowing any run of whitespace.
fn match_lit(s : String, j : Int32, text : String) -> Int32 raise Refused {
  let mut k = j
  for c = 0; c < text.length(); c = c + 1 {
    let ch = text[c].to_int()
    if ch == 0x20 {
      while k < s.length() && s[k].to_int() == 0x20 {
        k = k + 1
      }
    } else {
      if k >= s.length() || s[k].to_int() != ch {
        raise Malformed("text does not match the layout at \{k}")
      }
      k = k + 1
    }
  }
  k
}