// The format patterns of TO_STRING and TO_TIMESTAMP (Kotlin v0
// "TimeFormatPattern"; spec M4d3 §4.6).

///|
/// A token of a format pattern. Widths count the repeated letter.
priv enum Token {
  Year(Int32)
  Month(Int32)
  Day(Int32)
  Hour24(Int32)
  Hour12(Int32)
  AmPm
  Minute(Int32)
  Second(Int32)
  Fraction(Int32)
  Nano
  Offset(zulu~ : Bool, width~ : Int32)
  Literal(String)
}

///|
let month_names : Array[String] = [
  "January", "February", "March", "April", "May", "June", "July", "August", "September",
  "October", "November", "December",
]

///|
fn bad_pattern(pattern : String, why : String) -> OpFailure {
  DataFailure("invalid format pattern '\{pattern}': \{why}")
}

///|
/// The symbol of a run of `count` letters `c`; `None` for a run the pattern
/// language does not have.
fn symbol(c : Char, count : Int32) -> Token? {
  match (c, count) {
    ('y', 1 | 2 | 4) => Some(Year(count))
    ('M', 1..<=5) => Some(Month(count))
    ('d', 1 | 2) => Some(Day(count))
    ('H', 1 | 2) => Some(Hour24(count))
    ('h', 1 | 2) => Some(Hour12(count))
    ('a', 1) => Some(AmPm)
    ('m', 1 | 2) => Some(Minute(count))
    ('s', 1 | 2) => Some(Second(count))
    ('S', 1..<=9) => Some(Fraction(count))
    ('n', 1) => Some(Nano)
    ('X', 1..<=5) => Some(Offset(zulu=true, width=count))
    ('x', 1..<=5) => Some(Offset(zulu=false, width=count))
    _ => None
  }
}

///|
fn is_ascii_letter(c : Char) -> Bool {
  (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}

///|
/// The tokens of a pattern: runs of one ASCII letter are symbols, `'...'` is
/// quoted text (`''` is one quote), and any other character is itself.
fn tokenize(pattern : String) -> Array[Token] raise OpFailure {
  let chars = pattern.to_array()
  let n = chars.length()
  let out : Array[Token] = []
  let mut i = 0
  while i < n {
    let c = chars[i]
    if c == '\'' {
      if i + 1 < n && chars[i + 1] == '\'' {
        out.push(Literal("'"))
        i += 2
        continue
      }
      let buf = StringBuilder()
      i += 1
      let mut closed = false
      while i < n {
        if chars[i] == '\'' {
          if i + 1 < n && chars[i + 1] == '\'' {
            buf.write_char('\'')
            i += 2
          } else {
            i += 1
            closed = true
            break
          }
        } else {
          buf.write_char(chars[i])
          i += 1
        }
      }
      if !closed {
        raise bad_pattern(pattern, "a quote that is not closed")
      }
      out.push(Literal(buf.to_string()))
    } else if is_ascii_letter(c) {
      let mut j = i
      while j < n && chars[j] == c {
        j += 1
      }
      match symbol(c, j - i) {
        Some(token) => out.push(token)
        None =>
          raise bad_pattern(
            pattern,
            "'\{String::make(j - i, c)}' is not a pattern symbol",
          )
      }
      i = j
    } else {
      out.push(Literal(c.to_string()))
      i += 1
    }
  }
  out
}

///|
fn number_text(n : Int32, width : Int32) -> String {
  padded(n.to_int64(), width)
}

///|
fn offset_text(minutes : Int32, zulu : Bool, width : Int32) -> String {
  if minutes == 0 && zulu {
    return "Z"
  }
  let sign = if minutes < 0 { "-" } else { "+" }
  let a = minutes.abs()
  let hh = number_text(a / 60, 2)
  let mm = number_text(a % 60, 2)
  match width {
    1 => if a % 60 == 0 { sign + hh } else { sign + hh + mm }
    2 | 4 => sign + hh + mm
    _ => sign + hh + ":" + mm
  }
}

///|
/// A datetime formatted by the tokens. A symbol for a field the value lacks
/// is a data failure.
fn format_datetime(
  x : @value.Value,
  tokens : Array[Token],
) -> String raise OpFailure {
  let (date, time) : (@value.Date?, @value.Time?) = match x {
    Date(d) => (Some(d), None)
    Time(t) => (None, Some(t))
    Timestamp(ts) => (Some(ts.date), Some(ts.time))
    _ => raise TypeFailure("TO_STRING of \{kind_name(x)}")
  }
  let lacks = (what : String) => {
    DataFailure("TO_STRING: a \{kind_name(x)} has no \{what}")
  }
  let buf = StringBuilder()
  for token in tokens {
    match token {
      Literal(text) => buf.write_string(text)
      Year(_) | Month(_) | Day(_) => {
        guard date is Some(d) else { raise lacks("date") }
        let text = match token {
          Year(1) => d.year.to_string()
          Year(2) => number_text(d.year % 100, 2)
          Year(w) => number_text(d.year, w)
          Month(3) => month_names[d.month - 1].view(end_offset=3).to_owned()
          Month(4) => month_names[d.month - 1]
          Month(5) => month_names[d.month - 1].view(end_offset=1).to_owned()
          Month(w) => number_text(d.month, w)
          Day(w) => number_text(d.day, w)
          _ => ""
        }
        buf.write_string(text)
      }
      Offset(zulu~, width~) => {
        guard time is Some(t) && t.offset is Some(o) else {
          raise lacks("time zone offset")
        }
        buf.write_string(offset_text(o, zulu, width))
      }
      _ => {
        guard time is Some(t) else { raise lacks("time of day") }
        let text = match token {
          Hour24(w) => number_text(t.hour, w)
          Hour12(w) => {
            let h = t.hour % 12
            number_text(if h == 0 { 12 } else { h }, w)
          }
          AmPm => if t.hour < 12 { "AM" } else { "PM" }
          Minute(w) => number_text(t.minute, w)
          Second(w) => number_text(t.second, w)
          Fraction(k) => number_text(t.nanos, 9).view(end_offset=k).to_owned()
          Nano => t.nanos.to_string()
          _ => ""
        }
        buf.write_string(text)
      }
    }
  }
  buf.to_string()
}

///|
/// `TO_STRING(x, pattern)`.
pub fn datetime_to_string(
  args : Array[@value.Value],
) -> @value.Value raise OpFailure {
  apply("TO_STRING", [Datetime, Text], args, xs => {
    guard xs[1] is String(pattern) else {
      raise TypeFailure("the pattern of TO_STRING must be a string")
    }
    @value.Value::from_string(format_datetime(xs[0], tokenize(pattern)))
  })
}

///|
/// The rank of a field in the chain year > month > day > hour > minute >
/// second > fraction; `None` for AM/PM, offsets and text.
fn field_order(t : Token) -> Int32? {
  match t {
    Year(_) => Some(0)
    Month(_) => Some(1)
    Day(_) => Some(2)
    Hour24(_) | Hour12(_) => Some(3)
    Minute(_) => Some(4)
    Second(_) => Some(5)
    Fraction(_) | Nano => Some(6)
    _ => None
  }
}

///|
/// Whether a token reads a variable number of digits.
fn variable_width(t : Token) -> Bool {
  match t {
    Year(1)
    | Month(1)
    | Day(1)
    | Hour24(1)
    | Hour12(1)
    | Minute(1)
    | Second(1)
    | Nano => true
    _ => false
  }
}

///|
/// Whether a token reads digits.
fn numeric(t : Token) -> Bool {
  match t {
    Month(3 | 4 | 5) | AmPm | Offset(..) | Literal(_) => false
    _ => true
  }
}

///|
/// The pattern rules of TO_TIMESTAMP (spec M4d3 §4.6, Kotlin v0
/// FormatPattern): no MMMMM, no field twice, a year, an unbroken chain of
/// fields, an offset or AM/PM only with year, month, day and hour, `h` only
/// with `a`, `H` never with `a`, and no variable-width number directly
/// before another number.
fn check_parse_pattern(
  tokens : Array[Token],
  pattern : String,
) -> Unit raise OpFailure {
  let seen = Array::make(7, false)
  let mut am_pm = false
  let mut offset = false
  let mut hour12 = false
  let mut hour24 = false
  for i, t in tokens {
    if t is Month(5) {
      raise bad_pattern(pattern, "MMMMM is not valid for TO_TIMESTAMP")
    }
    match t {
      AmPm => {
        if am_pm {
          raise bad_pattern(pattern, "AM/PM twice")
        }
        am_pm = true
      }
      Offset(..) => {
        if offset {
          raise bad_pattern(pattern, "an offset twice")
        }
        offset = true
      }
      Hour12(_) => hour12 = true
      Hour24(_) => hour24 = true
      _ => ()
    }
    if field_order(t) is Some(r) {
      if seen[r] {
        raise bad_pattern(pattern, "a field twice")
      }
      seen[r] = true
    }
    if variable_width(t) && i + 1 < tokens.length() && numeric(tokens[i + 1]) {
      raise bad_pattern(pattern, "a variable-width number before a number")
    }
  }
  if !seen[0] {
    raise bad_pattern(pattern, "no year")
  }
  let mut last = 0
  for r in 0..<7 {
    if seen[r] {
      last = r
    }
  }
  for r in 0.. OpFailure {
  DataFailure("'\{text}' does not match the pattern")
}

///|
/// How many digits a number field reads: one letter reads all the digits
/// that follow; a repeated letter, and `S` x n, read exactly that many.
priv enum Width {
  Variable
  Fixed(Int32)
}

///|
/// The width of a field written with `count` letters.
fn letters_width(count : Int32) -> Width {
  if count == 1 {
    Variable
  } else {
    Fixed(count)
  }
}

///|
/// Digits at the cursor, by `width`. A variable-width number may start with
/// `-`, which the range check then rejects. At most 18 digits, so the value
/// fits Int64; more is out of range.
fn Scan::number(
  self : Scan,
  width : Width,
  text : String,
) -> Int64 raise OpFailure {
  let negative = width is Variable &&
    self.pos < self.chars.length() &&
    self.chars[self.pos] == '-'
  if negative {
    self.pos += 1
  }
  let start = self.pos
  while self.pos < self.chars.length() &&
        self.chars[self.pos] >= '0' &&
        self.chars[self.pos] <= '9' &&
        (match width {
          Variable => true
          Fixed(n) => self.pos - start < n
        }) {
    self.pos += 1
  }
  let count = self.pos - start
  if count == 0 || (width is Fixed(n) && count != n) {
    raise no_match(text)
  }
  if count > 18 {
    raise DataFailure("'\{text}' has a number out of range")
  }
  let mut value = 0L
  for i in start.. Bool {
  let w = word.to_array()
  if self.pos + w.length() > self.chars.length() {
    return false
  }
  for i, c in w {
    let t = self.chars[self.pos + i]
    let same = match letters {
      Exact => t == c
      AnyCase => t == c || t.to_ascii_lowercase() == c.to_ascii_lowercase()
    }
    if !same {
      return false
    }
  }
  self.pos += w.length()
  true
}

///|
fn Scan::next_is_digit(self : Scan) -> Bool {
  self.pos < self.chars.length() &&
  self.chars[self.pos] >= '0' &&
  self.chars[self.pos] <= '9'
}

///|
fn Scan::offset(
  self : Scan,
  zulu : Bool,
  width : Int32,
  text : String,
) -> Int32 raise OpFailure {
  if zulu && self.accept("Z", Exact) {
    return 0
  }
  let sign = if self.accept("+", Exact) {
    1
  } else if self.accept("-", Exact) {
    -1
  } else {
    raise no_match(text)
  }
  let hours = self.number(Fixed(2), text)
  let minutes = match width {
    1 => if self.next_is_digit() { self.number(Fixed(2), text) } else { 0L }
    2 | 4 => self.number(Fixed(2), text)
    _ => {
      if !self.accept(":", Exact) {
        raise no_match(text)
      }
      self.number(Fixed(2), text)
    }
  }
  // XXXX and XXXXX (and x) may carry seconds, which must be 0 (KDOC: an
  // offset must land on a minute boundary).
  let seconds = match width {
    4 if self.next_is_digit() => self.number(Fixed(2), text)
    5 if self.accept(":", Exact) => self.number(Fixed(2), text)
    _ => 0L
  }
  if seconds != 0L {
    raise DataFailure("'\{text}' has an offset that is not whole minutes")
  }
  if hours > 23L || minutes > 59L {
    raise DataFailure("'\{text}' has an offset out of range")
  }
  sign * (hours.to_int() * 60 + minutes.to_int())
}

///|
/// TO_TIMESTAMP with a pattern (spec M4d3 §4.6).
fn parse_with_pattern(
  text : String,
  pattern : String,
) -> @value.Value raise OpFailure {
  let tokens = tokenize(pattern)
  check_parse_pattern(tokens, pattern)
  let scan : Scan = { chars: text.to_array(), pos: 0, }
  let mut year = 0L
  let mut month = 1L
  let mut day = 1L
  let mut hour = 0L
  let mut minute = 0L
  let mut second = 0L
  let mut nanos = 0L
  let mut pm : Bool? = None
  let mut offset = 0
  let mut fraction = false
  for token in tokens {
    match token {
      Year(2) => {
        let v = scan.number(Fixed(2), text)
        year = if v < 70L { 2000L + v } else { 1900L + v }
      }
      Year(w) => year = scan.number(letters_width(w), text)
      Month(3 | 4) => {
        let mut found = false
        for i, name in month_names {
          let short = name.view(end_offset=3).to_owned()
          if (token is Month(4) && scan.accept(name, AnyCase)) ||
            (token is Month(3) && scan.accept(short, AnyCase)) {
            month = (i + 1).to_int64()
            found = true
            break
          }
        }
        if !found {
          raise no_match(text)
        }
      }
      Month(w) => month = scan.number(letters_width(w), text)
      Day(w) => day = scan.number(letters_width(w), text)
      Hour24(w) | Hour12(w) => hour = scan.number(letters_width(w), text)
      AmPm =>
        pm = if scan.accept("AM", AnyCase) {
          Some(false)
        } else if scan.accept("PM", AnyCase) {
          Some(true)
        } else {
          raise no_match(text)
        }
      Minute(w) => minute = scan.number(letters_width(w), text)
      Second(w) => second = scan.number(letters_width(w), text)
      Fraction(k) => {
        let v = scan.number(Fixed(k), text)
        let mut scale = 1L
        for _ in 0..<(9 - k) {
          scale = scale * 10L
        }
        nanos = v * scale
        fraction = true
      }
      Nano => {
        nanos = scan.number(Variable, text)
        fraction = true
      }
      Offset(zulu~, width~) => offset = scan.offset(zulu, width, text)
      Literal(s) => if !scan.accept(s, Exact) { raise no_match(text) }
    }
  }
  if scan.pos != scan.chars.length() {
    raise no_match(text)
  }
  let out_of_range = DataFailure("'\{text}' has a field out of range")
  if year < 1L || year > 9999L || month < 1L || month > 12L {
    raise out_of_range
  }
  if day < 1L || day > month_length(year.to_int(), month.to_int()).to_int64() {
    raise out_of_range
  }
  let hour24 = match pm {
    Some(after_noon) => {
      if hour < 1L || hour > 12L {
        raise out_of_range
      }
      hour % 12L + (if after_noon { 12L } else { 0L })
    }
    None => hour
  }
  if hour24 < 0L ||
    hour24 > 23L ||
    minute < 0L ||
    minute > 59L ||
    second < 0L ||
    second > 59L ||
    nanos < 0L ||
    nanos > 999999999L {
    raise out_of_range
  }
  let precision = if fraction { needed_precision(nanos.to_int()) } else { 0 }
  let date = @value.Date::Date(
    year=year.to_int(),
    month=month.to_int(),
    day=day.to_int(),
  ) catch {
    _ => raise out_of_range
  }
  let time = @value.Time::Time(
    hour=hour24.to_int(),
    minute=minute.to_int(),
    second=second.to_int(),
    nanos=nanos.to_int(),
    precision~,
    offset=Some(offset),
  ) catch {
    _ => raise out_of_range
  }
  @value.Value::from_timestamp(@value.Timestamp(date, time))
}

///|
/// TO_TIMESTAMP of Ion timestamp text (the whole string).
fn ion_timestamp_text(text : String) -> @value.Value raise OpFailure {
  let parsed = @ion_core.Timestamp::parse(text) catch {
    _ => raise DataFailure("'\{text}' is not an Ion timestamp")
  }
  let ts = @value.timestamp_of_ion(parsed)
  ignore(checked_date(ts.date))
  @value.Value::from_timestamp(ts)
}

///|
/// `TO_TIMESTAMP(text [, pattern])`.
pub fn text_to_timestamp(
  args : Array[@value.Value],
) -> @value.Value raise OpFailure {
  if args.length() == 1 {
    apply("TO_TIMESTAMP", [Text], args, xs => {
      guard xs[0] is String(text) else {
        raise TypeFailure("TO_TIMESTAMP needs a string")
      }
      ion_timestamp_text(text)
    })
  } else {
    apply("TO_TIMESTAMP", [Text, Text], args, xs => {
      guard xs[0] is String(text) && xs[1] is String(pattern) else {
        raise TypeFailure("TO_TIMESTAMP needs strings")
      }
      parse_with_pattern(text, pattern)
    })
  }
}