/// Parses a single timespec string into a `TimeSpec`.
///
/// A timespec is a flexible time expression that may contain a datetime,
/// a duration, or both. The general syntax is:
///
/// ```
/// [@] [sign] (duration | datetime | time-of-day)+ [@]
/// ```
///
/// - `@` can appear anywhere (leading, trailing, or between segments) and
///   forces the result to be `Absolute`. Multiple `@` are allowed (idempotent).
/// - A datetime (e.g. `2025-03-15T00:00:00Z`) produces `Absolute`.
/// - A duration alone (e.g. `5m`) produces `Relative` (resolved from `now`).
/// - Duration segments adjacent to a datetime act as offsets on that datetime.
/// - `@HH:MM[:SS[.mmm]][TZ]` resets to today's time-of-day (requires `@`).
///
/// Multi-pass parsing phases:
/// 1. Extract duration segments (number + unit [+ ago])
/// 2. Detect `@` markers
/// 3. Detect time-of-day pattern (when `@` present)
/// 4. Parse remaining as datetime
/// 5. Build EpochTime
/// 6. Build TimeSpec
///
/// Parameters:
///
/// * `input` : The timespec string (e.g. `"5m"`, `"@5m"`, `"2025-01-01T00:00:00Z+1h"`).
/// * `epoch` : Base epoch offset subtracted from absolute results. Defaults to Unix epoch.
/// * `default_sign` : How to interpret unsigned durations. Defaults to `Plus`.
/// * `default_tz_offset` : Default timezone for TZ-less datetimes. Defaults to `Local`.
/// * `now` : Clock function returning the current time in ms. Defaults to `@env.now`.
/// * `parse_datetime` : Pluggable datetime parser. Defaults to `default_parse_datetime`.
///
/// Returns `Some(TimeSpec)` for valid time expressions, or `None` for
/// explicit reset keywords (`none`, `null`, `nil`).
///
/// Raises `ParseError` on malformed input.

///|
pub fn parse_timespec(
  input : String,
  epoch? : EpochTime = EpochTime(0L),
  default_sign? : Sign = Plus,
  default_tz_offset? : TzOffset = Local,
  now? : () -> UInt64 = @env.now,
  parse_datetime? : (String) -> Int64? = default_parse_datetime,
) -> TimeSpec? raise ParseError {
  let s = input.trim()
  if s.length() == 0 {
    raise ParseError("empty timespec string")
  }
  // Check for none/null/nil keywords (explicit reset)
  let lower = s.to_lower()
  if lower == "none" || lower == "null" || lower == "nil" {
    return None
  }
  // Check for "now" keyword
  let is_now = lower == "now" ||
    lower == "@now" ||
    (
      lower.length() > 1 &&
      ({
        // Handle patterns like "@@now", "@@@now", etc.
        let mut all_at = true
        let mut i = 0
        while i < lower.length() - 3 {
          match lower.get_char(i) {
            Some('@') => i += 1
            _ => {
              all_at = false
              break
            }
          }
        }
        all_at && lower[i:] == "now"
      })
    )
  if is_now {
    let now_ms = now().reinterpret_as_int64()
    let epoch_time = EpochTime(now_ms - epoch.0)
    let has_at = s.to_lower().contains("@")
    if has_at {
      return Some(Absolute(epoch_time, Duration(0L)))
    } else {
      return Some(Relative(epoch_time, Duration(0L)))
    }
  }
  // ===== Phase 1: Leading sign + Duration segment extraction =====
  let mut pos = 0
  // Check for leading @
  let mut has_at = false
  match s.get_char(pos) {
    Some('@') => {
      has_at = true
      pos += 1
    }
    _ => ()
  }
  // Check for leading sign
  let mut needs_sign = false
  let mut leading_sign_char : Char? = None
  let negate : Bool = match s.get_char(pos) {
    Some('+') => {
      leading_sign_char = Some('+')
      pos += 1
      false
    }
    Some('-') => {
      leading_sign_char = Some('-')
      pos += 1
      true
    }
    _ =>
      match default_sign {
        Minus => true
        Plus => false
        Reject => {
          needs_sign = true
          false // Provisional value
        }
      }
  }
  pos = skip_spaces(s, pos)
  // Scan the string: accumulate duration segment ms directly, buffer non-duration parts
  let non_duration_buf = StringBuilder::new()
  let mut has_duration = false
  let mut total_duration_ms = 0L
  // Apply leading sign: negate applies to the first unsigned duration segment group
  // A segment group = consecutive duration segments delimited by explicit signs
  // The first group follows negate (leading sign); subsequent groups follow explicit signs
  let mut current_group_ms = 0L
  let mut current_group_sign : Int64 = if negate { -1L } else { 1L }
  let mut in_first_group = true // Whether we are still in the first group
  let mut scan_pos = pos
  while scan_pos < s.length() {
    scan_pos = skip_spaces(s, scan_pos)
    if scan_pos >= s.length() {
      break
    }
    match s.get_char(scan_pos) {
      Some('@') => {
        has_at = true
        non_duration_buf.write_char('@')
        scan_pos += 1
        continue
      }
      _ => ()
    }
    // Look ahead for a sign
    let seg_sign_start = scan_pos
    let mut seg_sign_char : Char? = None
    match s.get_char(scan_pos) {
      Some('+') => {
        seg_sign_char = Some('+')
        scan_pos += 1
      }
      Some('-') => {
        seg_sign_char = Some('-')
        scan_pos += 1
      }
      _ => ()
    }
    let after_sign = skip_spaces(s, scan_pos)
    // Check if it starts with a digit
    if is_digit_at(s, after_sign) {
      // Try as a duration segment candidate
      let probe_result = probe_duration_segment(s, after_sign)
      match probe_result {
        Some((seg_ms, is_ago, dur_end)) => {
          // Confirmed as a duration segment
          has_duration = true
          // Apply "ago" reversal
          let effective_ms = if is_ago { -seg_ms } else { seg_ms }
          match seg_sign_char {
            Some('+') => {
              // Explicit +: flush current group and start a new one
              if !in_first_group || has_duration {
                total_duration_ms = checked_add(
                  total_duration_ms,
                  checked_mul(
                    current_group_sign,
                    current_group_ms,
                    msg="timespec overflow",
                  ),
                  msg="timespec overflow",
                )
              }
              current_group_ms = effective_ms
              current_group_sign = 1L
              in_first_group = false
            }
            Some('-') => {
              // Explicit -: flush current group and start a new one
              if !in_first_group || has_duration {
                total_duration_ms = checked_add(
                  total_duration_ms,
                  checked_mul(
                    current_group_sign,
                    current_group_ms,
                    msg="timespec overflow",
                  ),
                  msg="timespec overflow",
                )
              }
              current_group_ms = effective_ms
              current_group_sign = -1L
              in_first_group = false
            }
            _ =>
              // No sign: accumulate into the current group
              current_group_ms = checked_add(
                current_group_ms,
                effective_ms,
                msg="timespec overflow",
              )
          }
          scan_pos = dur_end
          continue
        }
        None => {
          // Not a duration segment -> non-duration
          scan_pos = seg_sign_start
          let non_dur_end = scan_non_duration(s, scan_pos)
          non_duration_buf.write_string(s[scan_pos:non_dur_end].to_owned())
          scan_pos = non_dur_end
          continue
        }
      }
    } else {
      // Does not start with a digit -> non-duration
      scan_pos = seg_sign_start
      let non_dur_end = scan_non_duration(s, scan_pos)
      if non_dur_end == scan_pos {
        match s.get_char(scan_pos) {
          Some(c) => {
            non_duration_buf.write_char(c)
            scan_pos += 1
          }
          None => break
        }
      } else {
        non_duration_buf.write_string(s[scan_pos:non_dur_end].to_owned())
        scan_pos = non_dur_end
      }
      continue
    }
  }
  // Flush the last group
  if has_duration {
    total_duration_ms = checked_add(
      total_duration_ms,
      checked_mul(current_group_sign, current_group_ms, msg="timespec overflow"),
      msg="timespec overflow",
    )
  }
  let duration = Duration(total_duration_ms)
  // ===== Phase 2: @ marker detection (has_at already set during scan) =====
  // ===== Phase 3: Time-of-day pattern detection =====
  let remaining_raw = non_duration_buf.to_string()
  let remaining_no_at = remaining_raw.replace(old="@", new="").trim().to_owned()
  let mut time_of_day_ms : Int64? = None
  let mut tz_offset_timepart : TzOffset? = None
  let mut datetime_str : String = remaining_no_at
  if has_at && remaining_no_at.length() > 0 {
    let (dt_part, tod_part) = split_datetime_and_timeofday(remaining_no_at)
    datetime_str = dt_part
    if tod_part.length() > 0 {
      let (tod_epoch_ms, tod_tz) = parse_iso8601_with_tz(
        tod_part,
        default_tz_offset~,
      )
      time_of_day_ms = Some(tod_epoch_ms)
      tz_offset_timepart = tod_tz
    }
  }
  // ===== Phase 3.5: Raw epoch ms detection =====
  // With @ and remaining string is [+-]?digits only -> raw epoch ms (date -d @EPOCH convention)
  // Restore leading sign consumed in Phase 1
  let mut has_raw_epoch = false
  let mut raw_epoch_ms = 0L
  let raw_candidate = if datetime_str.length() > 0 && !has_duration {
    match leading_sign_char {
      Some(c) => c.to_string() + datetime_str
      None => datetime_str
    }
  } else {
    datetime_str
  }
  if has_at && raw_candidate.length() > 0 && is_signed_digits(raw_candidate) {
    raw_epoch_ms = parse_raw_epoch(raw_candidate)
    has_raw_epoch = true
    datetime_str = ""
  }
  // ===== Phase 4: Datetime parsing =====
  let mut has_datetime = false
  let mut dt_epoch_ms = 0L
  let mut tz_offset_datetime : TzOffset? = None
  if datetime_str.length() > 0 {
    match parse_datetime(datetime_str) {
      Some(epoch_ms) => {
        has_datetime = true
        dt_epoch_ms = epoch_ms
        // Detect TzOffset from the string suffix independently (parser-agnostic)
        tz_offset_datetime = detect_tz_suffix(datetime_str)
      }
      None =>
        raise ParseError(
          "failed to parse datetime: " + datetime_str + " in: " + input,
        )
    }
  }
  // ===== Phase 5: Build EpochTime =====
  // `@` alone with no duration/datetime/time-of-day → Absolute(now)
  if has_at &&
    !has_duration &&
    !has_datetime &&
    !has_raw_epoch &&
    time_of_day_ms is None {
    let now_ms = now().reinterpret_as_int64()
    return Some(Absolute(EpochTime(now_ms - epoch.0), Duration(0L)))
  }
  if !has_duration &&
    !has_datetime &&
    !has_raw_epoch &&
    time_of_day_ms is None &&
    !has_at {
    raise ParseError("no duration or datetime found in: " + input)
  }
  // Reject mode check: duration only with no datetime/time-of-day/raw epoch
  if needs_sign && !has_datetime && !has_raw_epoch && time_of_day_ms is None {
    raise ParseError("missing sign in timespec: " + input)
  }
  // Determine the base epoch_ms
  let mut base_epoch_ms = 0L
  if has_raw_epoch {
    base_epoch_ms = raw_epoch_ms
  } else if has_datetime {
    base_epoch_ms = dt_epoch_ms
  } else {
    base_epoch_ms = now().reinterpret_as_int64()
  }
  // Time-of-day reset
  match time_of_day_ms {
    Some(tod_ms) => {
      // TzOffset consistency check
      let effective_tod_tz = match tz_offset_timepart {
        Some(tz) => tz
        None => default_tz_offset
      }
      let effective_dt_tz = match tz_offset_datetime {
        Some(tz) => tz
        None => default_tz_offset
      }
      if has_datetime {
        if !effective_tod_tz.equal_offset(effective_dt_tz) {
          raise ParseError("ambiguous timezone offset")
        }
      }
      // Truncate base to local midnight
      let offset_minutes = tz_offset_to_minutes(effective_tod_tz)
      let offset_ms = offset_minutes.to_int64() * 60_000L
      let local_ms = base_epoch_ms + offset_ms
      let local_midnight = local_ms - floor_mod_i64(local_ms, 86_400_000L)
      let utc_midnight = local_midnight - offset_ms
      base_epoch_ms = utc_midnight + tod_ms
    }
    None => ()
  }
  // Add duration
  let final_epoch_ms = checked_add(
    base_epoch_ms - epoch.0,
    duration.0,
    msg="timespec overflow",
  )
  // ===== Phase 6: Build TimeSpec =====
  let epoch_time = EpochTime(final_epoch_ms)
  if has_at || has_datetime {
    Some(Absolute(epoch_time, duration))
  } else {
    Some(Relative(epoch_time, duration))
  }
}

/// Probes whether a duration segment (number + unit [+ ago]) starts at `pos`.
///
/// Returns `Some((ms_value, is_ago, end_pos))` if a valid duration segment is found,
/// or `None` if the number is not followed by a duration unit.
/// The returned `ms_value` is always positive; `is_ago` indicates the `ago` modifier.

///|
fn probe_duration_segment(
  s : StringView,
  pos : Int,
) -> (Int64, Bool, Int)? raise ParseError {
  // Read the digit part
  let mut p = pos
  while p < s.length() {
    match s.get_char(p) {
      Some(c) =>
        if c.is_ascii_digit() || c == '_' || c == '.' {
          p += 1
        } else {
          break
        }
      None => break
    }
  }
  if p == pos {
    return None
  }
  let after_num = skip_spaces(s, p)
  // Check if a unit character follows
  let unit_result = parse_unit(s, after_num) catch { _ => (0L, after_num) }
  let (ms_mul, unit_end) = unit_result
  if unit_end == after_num {
    return None // No unit found -> not a duration
  }
  // Actually parse the numeric value
  let (integer_part, fractional_part, _) = parse_number(s, pos)
  // Compute ms
  let mut seg_ms = checked_mul(integer_part, ms_mul, msg="timespec overflow")
  if fractional_part > 0.0 {
    let frac_ms = (fractional_part * ms_mul.to_double()).to_int64()
    seg_ms = checked_add(seg_ms, frac_ms, msg="timespec overflow")
  }
  // Check for "ago" modifier
  let after_unit = skip_spaces(s, unit_end)
  let (is_ago, end) = if try_match_word(s, after_unit, "ago") {
    (true, after_unit + 3)
  } else {
    (false, unit_end)
  }
  Some((seg_ms, is_ago, end))
}

/// Scans forward from `pos` collecting non-duration characters.
///
/// Stops at: whitespace, `@`, or a position where a `[+-]?digit+unit` pattern
/// could start (potential duration segment boundary).

///|
fn scan_non_duration(s : StringView, start : Int) -> Int {
  let mut pos = start
  while pos < s.length() {
    match s.get_char(pos) {
      Some(' ') | Some('\t') => break
      Some('@') => break
      Some('+') | Some('-') => {
        let after_sign = pos + 1
        if is_digit_at(s, after_sign) {
          let probe = probe_duration_segment(s, after_sign) catch { _ => None }
          match probe {
            Some(_) => break
            None => pos += 1
          }
        } else {
          pos += 1
        }
      }
      Some(c) =>
        if c.is_ascii_digit() {
          let probe = probe_duration_segment(s, pos) catch { _ => None }
          match probe {
            Some(_) => break
            None => pos += 1
          }
        } else {
          pos += 1
        }
      None => break
    }
  }
  pos
}

/// Splits a string into datetime and time-of-day parts.
///
/// If the string contains a 4-digit year pattern (YYYY followed by `-` or `/`),
/// it's treated as datetime. Otherwise, if it looks like a time pattern (contains `:`),
/// it's treated as time-of-day.

///|
fn split_datetime_and_timeofday(s : String) -> (String, String) {
  let sv : StringView = s
  // Search for a 4-digit year pattern
  let mut dt_start = -1
  let mut i = 0
  while i < sv.length() {
    if is_four_digits_at_sv(sv, i) {
      match sv.get_char(i + 4) {
        Some('-') | Some('/') => {
          dt_start = i
          break
        }
        _ => ()
      }
    }
    i += 1
  }
  if dt_start >= 0 {
    // Find the end of the datetime part
    let dt_end = find_datetime_end_sv(sv, dt_start)
    let dt_part = sv[dt_start:dt_end].to_owned().trim().to_owned()
    // Check if there is a time-of-day in non-datetime parts
    let before = sv[0:dt_start].to_owned().trim().to_owned()
    let after : String = if dt_end < sv.length() {
      sv[dt_end:sv.length()].to_owned().trim().to_owned()
    } else {
      ""
    }
    let tod : String = if before.contains(":") {
      before
    } else if after.contains(":") {
      after
    } else {
      ""
    }
    (dt_part, tod)
  } else if s.contains(":") {
    ("", s)
  } else {
    (s, "")
  }
}

/// Checks whether four consecutive ASCII digits exist starting at `pos` in a StringView.

///|
fn is_four_digits_at_sv(s : StringView, pos : Int) -> Bool {
  if pos + 4 > s.length() {
    return false
  }
  for i in 0..<4 {
    match s.get_char(pos + i) {
      Some(c) => if !c.is_ascii_digit() { return false }
      None => return false
    }
  }
  true
}

/// Finds the end of a datetime pattern starting at `start`.
/// Consumes digits, separators, T, colon, dot, Z, and timezone offset.

///|
fn find_datetime_end_sv(s : StringView, start : Int) -> Int {
  let mut pos = start
  let mut after_t = false
  while pos < s.length() {
    match s.get_char(pos) {
      Some(c) =>
        if c.is_ascii_digit() ||
          c == '-' ||
          c == '/' ||
          c == 'T' ||
          c == 't' ||
          c == ':' ||
          c == '.' ||
          c == 'Z' ||
          c == 'z' ||
          c == ' ' {
          if c == 'T' || c == 't' {
            after_t = true
          }
          // space: may act as T separator in datetime. End if next char is not a digit
          if c == ' ' {
            if !is_digit_at(s, pos + 1) {
              break
            }
          }
          pos += 1
        } else if (c == '+' || c == '-') && after_t {
          let tz_end = probe_tz_offset_sv(s, pos)
          if tz_end > pos {
            pos = tz_end
          } else {
            break
          }
        } else {
          break
        }
      None => break
    }
  }
  pos
}

/// Probes whether a +/- at sign_pos is a timezone offset.
/// Returns end position if TZ offset, or sign_pos if duration sign.

///|
fn probe_tz_offset_sv(s : StringView, sign_pos : Int) -> Int {
  let mut pos = sign_pos + 1
  let digit_start = pos
  while pos < s.length() {
    match s.get_char(pos) {
      Some(c) => if c.is_ascii_digit() { pos += 1 } else { break }
      None => break
    }
  }
  let digit_count = pos - digit_start
  if digit_count == 0 {
    return sign_pos
  }
  match s.get_char(pos) {
    Some('h') | Some('s') | Some('w') | Some('d') => return sign_pos
    Some('m') =>
      match s.get_char(pos + 1) {
        Some('s') => return sign_pos
        Some(c) => if !c.is_ascii_digit() { return sign_pos }
        None => return sign_pos
      }
    Some('u') | Some('n') | Some('\u03BC') => return sign_pos
    _ => ()
  }
  match s.get_char(pos) {
    Some(':') => {
      pos += 1
      while pos < s.length() {
        match s.get_char(pos) {
          Some(c) => if c.is_ascii_digit() { pos += 1 } else { break }
          None => break
        }
      }
    }
    _ => ()
  }
  pos
}

/// Compares two `TzOffset` values for equality by their resolved UTC offset in minutes.
///
/// `Local` is resolved to the system timezone before comparison.
/// For example, `Hour(0)` and `Utc` are considered equal since both resolve to 0 minutes.

///|
pub fn TzOffset::equal_offset(self : TzOffset, other : TzOffset) -> Bool {
  tz_offset_to_minutes(self) == tz_offset_to_minutes(other)
}

/// Floor modulo for Int64 that always returns a non-negative result.

///|
fn floor_mod_i64(a : Int64, b : Int64) -> Int64 {
  let r = a % b
  if r < 0L {
    r + b
  } else {
    r
  }
}

/// Checks if a string is [+-]?[0-9]+ (optional sign followed by digits only).

///|
fn is_signed_digits(s : String) -> Bool {
  let sv : StringView = s
  if sv.length() == 0 {
    return false
  }
  let mut i = 0
  // Skip leading sign
  match sv.get_char(0) {
    Some('+') | Some('-') => i = 1
    _ => ()
  }
  if i >= sv.length() {
    return false // Sign only
  }
  while i < sv.length() {
    match sv.get_char(i) {
      Some(c) => if !c.is_ascii_digit() { return false }
      None => return false
    }
    i += 1
  }
  true
}

/// Parses a signed digit string as raw epoch milliseconds.

///|
fn parse_raw_epoch(s : String) -> Int64 raise ParseError {
  let sv : StringView = s
  let mut i = 0
  let mut neg = false
  match sv.get_char(0) {
    Some('+') => i = 1
    Some('-') => {
      neg = true
      i = 1
    }
    _ => ()
  }
  let mut value = 0L
  let max_div = 922_337_203_685_477_580L // Int64::max_value / 10
  let max_mod = 7 // Int64::max_value % 10
  while i < sv.length() {
    match sv.get_char(i) {
      Some(c) => {
        let digit = c.to_int() - '0'.to_int()
        if value > max_div || (value == max_div && digit > max_mod) {
          raise ParseError("epoch value too large: " + s)
        }
        value = value * 10L + digit.to_int64()
        i += 1
      }
      None => break
    }
  }
  if neg {
    -value
  } else {
    value
  }
}