///|
fn digits(text : String) -> Int raise CheckError {
  let s = text.trim().to_owned()
  if s.is_empty() || s.length() > 9 || s.iter().any(c => c < '0' || c > '9') {
    raise Rejected("integer", 0, "expected bounded unsigned decimal: " + s)
  }
  @strconv.parse_int(s) catch {
    _ => raise Rejected("integer", 0, "overflow")
  }
}

///|
fn leap(y : Int) -> Bool {
  y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
}

///|
fn day_ticks(y : Int, m : Int, d : Int) -> Int64 raise CheckError {
  if y < 1980 || y > 2100 || m < 1 || m > 12 {
    raise Rejected("date", 0, "supported calendar years 1980..2100")
  }
  let months = [
    31,
    if leap(y) {
      29
    } else {
      28
    },
    31,
    30,
    31,
    30,
    31,
    31,
    30,
    31,
    30,
    31,
  ]
  if d < 1 || d > months[m - 1] {
    raise Rejected("date", 0, "invalid calendar day")
  }
  let mut days = d - 1
  for year in 1980.. Int64 raise CheckError {
  let s = text.trim().to_owned()
  let parts = s.split(".").map(v => v.to_owned()).collect()
  if parts.length() < 1 || parts.length() > 2 {
    raise Rejected("seconds", 0, "invalid decimal seconds")
  }
  let whole = digits(parts[0])
  let mut fraction = 0
  if parts.length() == 2 {
    let f = parts[1]
    if f.length() > 7 || f.is_empty() {
      raise Rejected("seconds", 0, "at most 7 fractional decimal digits")
    }
    fraction = digits(f)
    for _ in f.length()..<7 {
      fraction *= 10
    }
  }
  whole.to_int64() * 10000000L + fraction.to_int64()
}

///|
fn timestamp(
  y : String,
  m : String,
  d : String,
  h : String,
  minute : String,
  second : String,
) -> Int64 raise CheckError {
  let hour = digits(h)
  let min = digits(minute)
  let sec = seconds_ticks(second)
  if hour > 23 || min > 59 || sec >= 600000000L {
    raise Rejected("time", 0, "invalid time or unsupported leap second")
  }
  day_ticks(digits(y), digits(m), digits(d)) +
  (hour * 3600 + min * 60).to_int64() * 10000000L +
  sec
}

///|
pub fn Window::epn_daily(
  year : Int,
  month : Int,
  day : Int,
) -> Window raise CheckError {
  let start = day_ticks(year, month, day)
  { start, stop: start + 864000000000L, kind: "EPN daily GPS 30s subset", }
}

///|
pub fn Window::epn_hourly(
  year : Int,
  month : Int,
  day : Int,
  hour : Int,
) -> Window raise CheckError {
  if hour < 0 || hour > 23 {
    raise Rejected("profile", 0, "hour must be 0..23")
  }
  let start = day_ticks(year, month, day) + hour.to_int64() * 36000000000L
  { start, stop: start + 36000000000L, kind: "EPN hourly GPS 30s subset", }
}

///|
fn part(s : String, start : Int, end : Int) -> String {
  if start >= s.length() {
    ""
  } else {
    s[start:s.length().min(end)].to_owned()
  }
}

///|
fn trimmed(s : String, start : Int, end : Int) -> String {
  part(s, start, end).trim().to_owned()
}

///|
fn decimal(text : String) -> Double raise CheckError {
  let s = text.trim().to_owned()
  if s.is_empty() ||
    s
    .iter()
    .any(c => {
      !(c >= '0' && c <= '9') &&
      c != '+' &&
      c != '-' &&
      c != '.' &&
      c != 'E' &&
      c != 'e' &&
      c != 'D' &&
      c != 'd'
    }) {
    raise Rejected("observation-number", 0, "invalid numeric field")
  }
  let normalized = s.replace(old="D", new="E").replace(old="d", new="e")
  let n = @strconv.parse_double(normalized) catch {
    _ => raise Rejected("observation-number", 0, "invalid decimal")
  }
  if n.is_nan() || n.is_inf() {
    raise Rejected("observation-number", 0, "nonfinite observation")
  }
  n
}