// `Date.parse` for the text formats that show up in chart data.
//
// V8's legacy date parser accepts a very large, loosely defined set of
// strings. This port handles ISO 8601 (ES5 format and the common space
// separated variants), `YYYY/MM/DD`, `M/D/YYYY`, month names in either order,
// weekday names, 12 and 24 hour times, and GMT / UTC / numeric offsets. Other
// inputs yield NaN (an invalid date). Local time is UTC (see the README).

///|
priv enum DateToken {
  Number(Double, Int32)
  Word(String)
  Sym(Char)
}

///|
fn tokenize(s : String) -> Array[DateToken] {
  let cs = s.to_array()
  let n = cs.length()
  let out : Array[DateToken] = []
  let mut i = 0
  while i < n {
    let c = cs[i]
    if c >= '0' && c <= '9' {
      let mut v = 0.0
      let start = i
      while i < n && cs[i] >= '0' && cs[i] <= '9' {
        v = v * 10.0 + (cs[i].to_int() - 48).to_double()
        i = i + 1
      }
      out.push(Number(v, i - start))
    } else if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
      let sb = StringBuilder()
      while i < n &&
            ((cs[i] >= 'a' && cs[i] <= 'z') || (cs[i] >= 'A' && cs[i] <= 'Z')) {
        sb.write_char(cs[i])
        i = i + 1
      }
      out.push(Word(sb.to_string().to_lower()))
    } else if c == '(' {
      // parenthesised text (e.g. a time zone name) is ignored
      while i < n && cs[i] != ')' {
        i = i + 1
      }
      i = i + 1
    } else {
      out.push(Sym(c))
      i = i + 1
    }
  }
  out
}

///|
fn month_of_word(w : String) -> Int32? {
  if w.length() < 3 {
    return None
  }
  let p = w.unsafe_substring(start=0, end=3)
  let names = [
    "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov",
    "dec",
  ]
  for i in 0..<12 {
    if names[i] == p {
      return Some(i + 1)
    }
  }
  None
}

///|
fn is_weekday_word(w : String) -> Bool {
  if w.length() < 3 {
    return false
  }
  let p = w.unsafe_substring(start=0, end=3)
  p == "sun" ||
  p == "mon" ||
  p == "tue" ||
  p == "wed" ||
  p == "thu" ||
  p == "fri" ||
  p == "sat"
}

///|
/// Offset in minutes east of UTC for a zone abbreviation, as V8 knows them.
fn zone_of_word(w : String) -> Int32? {
  match w {
    "z" | "gmt" | "utc" | "ut" => Some(0)
    "est" => Some(-300)
    "edt" => Some(-240)
    "cst" => Some(-360)
    "cdt" => Some(-300)
    "mst" => Some(-420)
    "mdt" => Some(-360)
    "pst" => Some(-480)
    "pdt" => Some(-420)
    _ => None
  }
}

///|
fn two_digit_year(y : Double, digits : Int32) -> Double {
  if digits <= 2 && y >= 0.0 && y < 50.0 {
    2000.0 + y
  } else if digits <= 2 && y >= 50.0 && y < 100.0 {
    1900.0 + y
  } else {
    y
  }
}

///|
fn finish_date(
  y : Double,
  mo : Double,
  d : Double,
  h : Double,
  mi : Double,
  s : Double,
  ms : Double,
  offset_minutes : Double,
) -> Double {
  if mo < 1.0 || mo > 12.0 || d < 1.0 || d > 31.0 {
    return @double.not_a_number
  }
  if h > 24.0 || mi > 59.0 || s > 59.0 {
    return @double.not_a_number
  }
  if h == 24.0 && (mi != 0.0 || s != 0.0 || ms != 0.0) {
    return @double.not_a_number
  }
  let t = @time.utc_ms_exact_year(y, mo - 1.0, d, h, mi, s, ms)
  t - offset_minutes * 60000.0
}

///|
fn ms_from_digits(text : String) -> Double {
  let cs = text.to_array()
  let mut v = 0
  for i in 0..<3 {
    v = v * 10 + (if i < cs.length() { cs[i].to_int() - 48 } else { 0 })
  }
  v.to_double()
}

///|
fn digits_at(cs : Array[Char], i : Int32, n : Int32) -> Double? {
  if i + n > cs.length() {
    return None
  }
  let mut v = 0.0
  for k in 0.. '9' {
      return None
    }
    v = v * 10.0 + (c.to_int() - 48).to_double()
  }
  Some(v)
}

///|
/// ECMAScript date-time string format (and its space-separated relatives).
fn parse_iso(s : String) -> Double? {
  let cs = s.to_array()
  let n = cs.length()
  let mut i = 0
  let mut year = 0.0
  if n > 0 && (cs[0] == '+' || cs[0] == '-') {
    let sign = if cs[0] == '-' { -1.0 } else { 1.0 }
    match digits_at(cs, 1, 6) {
      Some(v) => {
        if sign < 0.0 && v == 0.0 {
          return Some(@double.not_a_number)
        }
        year = sign * v
        i = 7
      }
      None => return None
    }
  } else {
    match digits_at(cs, 0, 4) {
      Some(v) => {
        year = v
        i = 4
      }
      None => return None
    }
  }
  let mut month = 1.0
  let mut day = 1.0
  if i < n && cs[i] == '-' {
    match digits_at(cs, i + 1, 2) {
      Some(v) => {
        month = v
        i = i + 3
      }
      None => return None
    }
    if i < n && cs[i] == '-' {
      match digits_at(cs, i + 1, 2) {
        Some(v) => {
          day = v
          i = i + 3
        }
        None => return None
      }
    }
  }
  let mut hour = 0.0
  let mut minute = 0.0
  let mut second = 0.0
  let mut milli = 0.0
  let mut offset = 0.0
  if i < n {
    if cs[i] != 'T' && cs[i] != 't' && cs[i] != ' ' {
      return None
    }
    i = i + 1
    match digits_at(cs, i, 2) {
      Some(v) => hour = v
      None => return None
    }
    if i + 2 >= n || cs[i + 2] != ':' {
      return None
    }
    match digits_at(cs, i + 3, 2) {
      Some(v) => minute = v
      None => return None
    }
    i = i + 5
    if i < n && cs[i] == ':' {
      match digits_at(cs, i + 1, 2) {
        Some(v) => second = v
        None => return None
      }
      i = i + 3
      if i < n && (cs[i] == '.' || cs[i] == ',') {
        let sb = StringBuilder()
        i = i + 1
        while i < n && cs[i] >= '0' && cs[i] <= '9' {
          sb.write_char(cs[i])
          i = i + 1
        }
        let text = sb.to_string()
        if text == "" {
          return None
        }
        milli = ms_from_digits(text)
      }
    }
    if i < n {
      if cs[i] == 'Z' || cs[i] == 'z' {
        i = i + 1
      } else if cs[i] == '+' || cs[i] == '-' {
        let sign = if cs[i] == '-' { -1.0 } else { 1.0 }
        match digits_at(cs, i + 1, 2) {
          Some(hh) => {
            let mut j = i + 3
            let mut mm = 0.0
            if j < n && cs[j] == ':' {
              j = j + 1
            }
            match digits_at(cs, j, 2) {
              Some(v) => {
                mm = v
                j = j + 2
              }
              None => return None
            }
            offset = sign * (hh * 60.0 + mm)
            i = j
          }
          None => return None
        }
      }
    }
  }
  if i != n {
    return None
  }
  Some(finish_date(year, month, day, hour, minute, second, milli, offset))
}

///|
/// V8-style legacy parsing of loosely formatted dates.
fn parse_legacy(s : String) -> Double {
  let toks = tokenize(s)
  let n = toks.length()
  let nums : Array[(Double, Int32)] = []
  let mut month : Int32? = None
  let mut hour : Double? = None
  let mut minute = 0.0
  let mut second = 0.0
  let mut milli = 0.0
  let mut ampm : Int32? = None
  let mut zone : Int32? = None
  let mut seen_number = false
  let mut i = 0
  while i < n {
    match toks[i] {
      Number(v, digits) => {
        seen_number = true
        let next_colon = i + 1 < n && toks[i + 1] is Sym(':')
        if next_colon && hour is None {
          hour = Some(v)
          i = i + 2
          match toks.get(i) {
            Some(Number(m, _)) => {
              minute = m
              i = i + 1
            }
            _ => return @double.not_a_number
          }
          if i < n && toks[i] is Sym(':') {
            i = i + 1
            match toks.get(i) {
              Some(Number(sv, _)) => {
                second = sv
                i = i + 1
              }
              _ => return @double.not_a_number
            }
            if i < n && toks[i] is Sym('.') {
              i = i + 1
              match toks.get(i) {
                Some(Number(_, digits_f)) => {
                  // re-read the digits of the fraction from the source text
                  let frac = match toks[i] {
                    Number(fv, _) => fv
                    _ => 0.0
                  }
                  let text = padded_digits(frac, digits_f)
                  milli = ms_from_digits(text)
                  i = i + 1
                }
                _ => return @double.not_a_number
              }
            }
          }
        } else if i > 0 &&
          (toks[i - 1] is Sym('+') || toks[i - 1] is Sym('-')) &&
          (hour is Some(_) || zone is Some(_)) {
          // numeric offset after a time or a zone word, e.g. "10:00 +0100", "GMT+0100"
          let sign = if toks[i - 1] is Sym('-') { -1 } else { 1 }
          let (hh, mm, adv) = parse_offset(toks, i, v, digits)
          zone = Some(sign * (hh * 60 + mm))
          i = i + adv
        } else {
          nums.push((v, digits))
          i = i + 1
        }
      }
      Word(w) => {
        if month_of_word(w) is Some(m) && month is None {
          month = Some(m)
        } else if is_weekday_word(w) {
          ()
        } else if w == "am" || w == "pm" {
          ampm = Some(if w == "pm" { 1 } else { 0 })
        } else if zone_of_word(w) is Some(z) {
          zone = Some(z)
        } else if w == "t" {
          ()
        } else if !seen_number {
          ()
        } else {
          return @double.not_a_number
        }
        i = i + 1
      }
      Sym(_) => i = i + 1
    }
  }
  // assemble the date part
  let (y, mo, d) = match month {
    Some(m) =>
      match nums.length() {
        1 => {
          let (a, ad) = nums[0]
          if a <= 31.0 && ad <= 2 {
            (2001.0, m.to_double(), a)
          } else {
            (two_digit_year(a, ad), m.to_double(), 1.0)
          }
        }
        2 => {
          let (a, ad) = nums[0]
          let (b, bd) = nums[1]
          if a > 31.0 || ad >= 3 {
            (two_digit_year(a, ad), m.to_double(), b)
          } else {
            (two_digit_year(b, bd), m.to_double(), a)
          }
        }
        _ => return @double.not_a_number
      }
    None =>
      match nums.length() {
        3 => {
          let (a, ad) = nums[0]
          let (b, _) = nums[1]
          let (c, cd) = nums[2]
          if a > 31.0 || ad >= 3 {
            (two_digit_year(a, ad), b, c)
          } else {
            (two_digit_year(c, cd), a, b)
          }
        }
        2 => {
          let (a, _) = nums[0]
          let (b, _) = nums[1]
          (2001.0, a, b)
        }
        1 => {
          let (a, ad) = nums[0]
          if a >= 1.0 && a <= 12.0 && ad <= 2 {
            (2001.0, a, 1.0)
          } else if a == 0.0 || a >= 32.0 {
            (two_digit_year(a, ad), 1.0, 1.0)
          } else {
            return @double.not_a_number
          }
        }
        _ => return @double.not_a_number
      }
  }
  let mut h = match hour {
    Some(v) => v
    None => 0.0
  }
  match ampm {
    Some(p) => {
      if h < 1.0 || h > 12.0 {
        return @double.not_a_number
      }
      h = h.mod(12.0) + (if p == 1 { 12.0 } else { 0.0 })
    }
    None => ()
  }
  let off = match zone {
    Some(z) => z.to_double()
    None => 0.0
  }
  finish_date(y, mo, d, h, minute, second, milli, off)
}

///|
fn padded_digits(v : Double, digits : Int32) -> String {
  let s = v.to_int64().to_string()
  "0".repeat(if digits > s.length() { digits - s.length() } else { 0 }) + s
}

///|
/// Hours / minutes of a numeric UTC offset starting at token `i` (value `v`
/// with `digits` digits); returns how many tokens were consumed.
fn parse_offset(
  toks : Array[DateToken],
  i : Int32,
  v : Double,
  digits : Int32,
) -> (Int32, Int32, Int32) {
  if digits >= 3 {
    let iv = v.to_int()
    (iv / 100, iv % 100, 1)
  } else {
    let hh = v.to_int()
    if i + 2 < toks.length() && toks[i + 1] is Sym(':') {
      match toks[i + 2] {
        Number(m, _) => (hh, m.to_int(), 3)
        _ => (hh, 0, 1)
      }
    } else {
      (hh, 0, 1)
    }
  }
}

///|
/// JavaScript's `Date.parse(text)`: epoch milliseconds, or NaN for an
/// unrecognised string.
pub fn parse_date(text : String) -> Double {
  let s = trim_js(text)
  if s == "" {
    return @double.not_a_number
  }
  match parse_iso(s) {
    Some(t) => t
    None =>
      // V8 treats a malformed ISO date-time (with a 'T' after the date) as
      // invalid instead of falling back to the lenient parser
      if looks_like_iso_datetime(s) {
        @double.not_a_number
      } else {
        parse_legacy(s)
      }
  }
}

///|
fn looks_like_iso_datetime(s : String) -> Bool {
  let cs = s.to_array()
  cs.length() > 10 &&
  (cs[10] == 'T' || cs[10] == 't') &&
  digits_at(cs, 0, 4) is Some(_) &&
  cs[4] == '-' &&
  digits_at(cs, 5, 2) is Some(_) &&
  cs[7] == '-' &&
  digits_at(cs, 8, 2) is Some(_)
}