Skip to main content

uni_query_functions/
datetime.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Temporal functions for Cypher query evaluation.
5//!
6//! Provides date, time, datetime, and duration constructors along with
7//! extraction functions compatible with OpenCypher temporal types.
8
9use anyhow::{Result, anyhow};
10use chrono::{
11    DateTime, Datelike, Duration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Offset,
12    TimeZone, Timelike, Utc, Weekday,
13};
14use chrono_tz::Tz;
15use std::collections::HashMap;
16// Re-export TemporalType so downstream modules (expr_eval, etc.) that import from
17// `crate::datetime::TemporalType` continue to work.
18pub use uni_common::TemporalType;
19use uni_common::{TemporalValue, Value};
20
21// ============================================================================
22// Constants
23// ============================================================================
24
25const MICROS_PER_SECOND: i64 = 1_000_000;
26const MICROS_PER_MINUTE: i64 = 60 * MICROS_PER_SECOND;
27const MICROS_PER_HOUR: i64 = 60 * MICROS_PER_MINUTE;
28const MICROS_PER_DAY: i64 = 24 * MICROS_PER_HOUR;
29const SECONDS_PER_DAY: i64 = 86_400;
30const NANOS_PER_SECOND: i64 = 1_000_000_000;
31const NANOS_PER_DAY: i64 = 24 * 3600 * NANOS_PER_SECOND;
32
33/// Classify a string value into its temporal type using pattern detection.
34pub fn classify_temporal(s: &str) -> Option<TemporalType> {
35    // Strip bracketed timezone suffix for classification
36    let base = if let Some(bracket_pos) = s.find('[') {
37        &s[..bracket_pos]
38    } else {
39        s
40    };
41
42    // Duration: starts with P (case insensitive)
43    if base.starts_with(['P', 'p']) {
44        return Some(TemporalType::Duration);
45    }
46
47    // Check for date component (YYYY-MM-DD pattern)
48    let has_date = base.len() >= 10
49        && base.as_bytes().get(4) == Some(&b'-')
50        && base.as_bytes().get(7) == Some(&b'-')
51        && base[..4].bytes().all(|b| b.is_ascii_digit())
52        && base[5..7].bytes().all(|b| b.is_ascii_digit())
53        && base[8..10].bytes().all(|b| b.is_ascii_digit());
54
55    // Check for T separator indicating datetime
56    let has_t = has_date && base.len() > 10 && base.as_bytes().get(10) == Some(&b'T');
57
58    if has_date && has_t {
59        // Has both date and time components
60        let after_t = &base[11..];
61        if has_timezone_suffix(after_t) {
62            Some(TemporalType::DateTime)
63        } else {
64            Some(TemporalType::LocalDateTime)
65        }
66    } else if has_date {
67        Some(TemporalType::Date)
68    } else {
69        // Try time patterns: HH:MM:SS or HH:MM:SS.fff
70        let has_time = base.len() >= 5
71            && base.as_bytes().get(2) == Some(&b':')
72            && base[..2].bytes().all(|b| b.is_ascii_digit())
73            && base[3..5].bytes().all(|b| b.is_ascii_digit());
74
75        if has_time {
76            if has_timezone_suffix(base) {
77                Some(TemporalType::Time)
78            } else {
79                Some(TemporalType::LocalTime)
80            }
81        } else {
82            None
83        }
84    }
85}
86
87/// Check if a temporal string suffix contains timezone information.
88fn has_timezone_suffix(s: &str) -> bool {
89    if s.ends_with(['Z', 'z']) {
90        return true;
91    }
92    // Look for +HH:MM or -HH:MM at the end, accounting for possible [timezone]
93    // Find last occurrence of + or - that could be a timezone offset
94    for (i, b) in s.bytes().enumerate().rev() {
95        if b == b'+' || b == b'-' {
96            let after = &s[i + 1..];
97            if after.len() >= 4
98                && after[..2].bytes().all(|b| b.is_ascii_digit())
99                && after.as_bytes().get(2) == Some(&b':')
100            {
101                return true;
102            }
103            // Could be +HHMM format
104            if after.len() >= 4 && after[..4].bytes().all(|b| b.is_ascii_digit()) {
105                return true;
106            }
107        }
108    }
109    false
110}
111
112/// Parse a duration from a Value, handling temporal durations, ISO 8601 strings, and integer microseconds.
113pub fn parse_duration_from_value(val: &Value) -> Result<CypherDuration> {
114    match val {
115        Value::Temporal(TemporalValue::Duration {
116            months,
117            days,
118            nanos,
119        }) => Ok(CypherDuration::new(*months, *days, *nanos)),
120        Value::Map(map) => {
121            if let Some(Value::Map(inner)) = map.get("Duration")
122                && let (Some(months), Some(days), Some(nanos)) = (
123                    inner.get("months").and_then(Value::as_i64),
124                    inner.get("days").and_then(Value::as_i64),
125                    inner.get("nanos").and_then(Value::as_i64),
126                )
127            {
128                return Ok(CypherDuration::new(months, days, nanos));
129            }
130            Err(anyhow!("Expected duration value"))
131        }
132        Value::String(s) => parse_duration_to_cypher(s),
133        Value::Int(micros) => Ok(CypherDuration::from_micros(*micros)),
134        _ => Err(anyhow!("Expected duration value")),
135    }
136}
137
138// ============================================================================
139// Timezone Handling
140// ============================================================================
141
142/// Parsed timezone information.
143#[derive(Debug, Clone)]
144pub enum TimezoneInfo {
145    /// Fixed offset timezone (e.g., +01:00, -05:00, Z)
146    FixedOffset(FixedOffset),
147    /// Named IANA timezone (e.g., Europe/Stockholm)
148    Named(Tz),
149}
150
151impl TimezoneInfo {
152    /// Get the offset in seconds for a given local datetime.
153    pub fn offset_for_local(&self, ndt: &NaiveDateTime) -> Result<FixedOffset> {
154        match self {
155            TimezoneInfo::FixedOffset(fo) => Ok(*fo),
156            TimezoneInfo::Named(tz) => {
157                // Get the offset for the given local time
158                match tz.from_local_datetime(ndt) {
159                    chrono::LocalResult::Single(dt) => Ok(dt.offset().fix()),
160                    chrono::LocalResult::Ambiguous(dt1, _dt2) => {
161                        // During DST transition, pick the earlier one (standard time)
162                        Ok(dt1.offset().fix())
163                    }
164                    chrono::LocalResult::None => {
165                        // Time doesn't exist (DST gap), find the closest valid time
166                        Err(anyhow!("Local time does not exist in timezone (DST gap)"))
167                    }
168                }
169            }
170        }
171    }
172
173    /// Get the offset for a given UTC datetime (no ambiguity possible).
174    pub fn offset_for_utc(&self, utc_ndt: &NaiveDateTime) -> FixedOffset {
175        match self {
176            TimezoneInfo::FixedOffset(fo) => *fo,
177            TimezoneInfo::Named(tz) => tz.from_utc_datetime(utc_ndt).offset().fix(),
178        }
179    }
180
181    /// Get the timezone name for output formatting.
182    fn name(&self) -> Option<&str> {
183        match self {
184            TimezoneInfo::FixedOffset(_) => None,
185            TimezoneInfo::Named(tz) => Some(tz.name()),
186        }
187    }
188
189    /// Get offset seconds for a fixed offset timezone, or for a named timezone at a given date.
190    fn offset_seconds_with_date(&self, date: &NaiveDate) -> i32 {
191        match self {
192            TimezoneInfo::FixedOffset(fo) => fo.local_minus_utc(),
193            TimezoneInfo::Named(tz) => {
194                // Use noon on the date to calculate offset (avoids DST transition edge cases)
195                let noon = NaiveTime::from_hms_opt(12, 0, 0).unwrap();
196                let ndt = NaiveDateTime::new(*date, noon);
197                match tz.from_local_datetime(&ndt) {
198                    chrono::LocalResult::Single(dt) => dt.offset().fix().local_minus_utc(),
199                    chrono::LocalResult::Ambiguous(dt1, _) => dt1.offset().fix().local_minus_utc(),
200                    chrono::LocalResult::None => 0, // Fallback, shouldn't happen at noon
201                }
202            }
203        }
204    }
205}
206
207/// Parse timezone - supports fixed offsets (+01:00) and IANA names (Europe/Stockholm).
208fn parse_timezone(tz_str: &str) -> Result<TimezoneInfo> {
209    let tz_str = tz_str.trim();
210
211    // Try parsing as IANA timezone name first
212    if let Ok(tz) = tz_str.parse::<Tz>() {
213        return Ok(TimezoneInfo::Named(tz));
214    }
215
216    // Try parsing as fixed offset
217    let offset_secs = parse_timezone_offset(tz_str)?;
218    let offset = FixedOffset::east_opt(offset_secs)
219        .ok_or_else(|| anyhow!("Invalid timezone offset: {}", offset_secs))?;
220    Ok(TimezoneInfo::FixedOffset(offset))
221}
222
223// ============================================================================
224// Public API
225// ============================================================================
226
227/// Parse a datetime string into a `DateTime<Utc>`.
228///
229/// Supports multiple formats:
230/// - RFC3339 (e.g., "2023-01-01T00:00:00Z")
231/// - "%Y-%m-%d %H:%M:%S %z" (e.g., "2023-01-01 00:00:00 +0000")
232/// - "%Y-%m-%d %H:%M:%S" naive (assumed UTC)
233///
234/// This is the canonical datetime parsing function for temporal operations
235/// like `validAt`. Using a single implementation ensures consistent behavior.
236pub fn parse_datetime_utc(s: &str) -> Result<DateTime<Utc>> {
237    // Temporal string renderings in the engine can include a bracketed timezone
238    // suffix (e.g. "2020-01-01T00:00Z[UTC]"). Strip it for parsing while keeping
239    // the explicit offset/UTC marker in the base datetime.
240    let s = s.trim();
241    let parse_input = match s.rfind('[') {
242        Some(pos) if s.ends_with(']') => &s[..pos],
243        _ => s,
244    };
245
246    DateTime::parse_from_rfc3339(parse_input)
247        .map(|dt: DateTime<FixedOffset>| dt.with_timezone(&Utc))
248        .or_else(|_| {
249            // Handle formats without seconds (e.g., "2023-01-01T00:00Z")
250            if let Some(base) = parse_input.strip_suffix('Z') {
251                NaiveDateTime::parse_from_str(base, "%Y-%m-%dT%H:%M")
252                    .map(|ndt| DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc))
253            } else {
254                // Handle formats without seconds with offset (e.g., "2023-01-01T00:00+05:00")
255                DateTime::parse_from_str(parse_input, "%Y-%m-%dT%H:%M%:z")
256                    .map(|dt: DateTime<FixedOffset>| dt.with_timezone(&Utc))
257            }
258        })
259        .or_else(|_| {
260            DateTime::parse_from_str(parse_input, "%Y-%m-%d %H:%M:%S %z")
261                .map(|dt: DateTime<FixedOffset>| dt.with_timezone(&Utc))
262        })
263        .or_else(|_| {
264            NaiveDateTime::parse_from_str(parse_input, "%Y-%m-%d %H:%M:%S")
265                .map(|ndt| DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc))
266        })
267        .map_err(|_| anyhow!("Invalid datetime format: {}", s))
268}
269
270/// Evaluate a temporal function using a frozen statement clock.
271///
272/// Routes to the appropriate handler based on function name. Supports:
273/// - Basic constructors: DATE, TIME, DATETIME, LOCALDATETIME, LOCALTIME, DURATION
274/// - Extraction: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND
275/// - Dotted namespace functions: DATETIME.FROMEPOCH, DATE.TRUNCATE, etc.
276///
277/// For zero-arg temporal constructors (e.g. `time()`, `datetime()`), uses the
278/// provided `frozen_now` instead of calling `Utc::now()`.  This ensures that
279/// all occurrences within the same statement return an identical value, as
280/// required by the OpenCypher specification.
281pub fn eval_datetime_function_with_clock(
282    name: &str,
283    args: &[Value],
284    frozen_now: chrono::DateTime<chrono::Utc>,
285) -> Result<Value> {
286    // Zero-arg temporal constructors use the frozen clock
287    if args.is_empty() {
288        match name {
289            "DATE" | "DATE.STATEMENT" | "DATE.TRANSACTION" => {
290                let d = frozen_now.date_naive();
291                return Ok(Value::Temporal(TemporalValue::Date {
292                    days_since_epoch: date_to_days_since_epoch(&d),
293                }));
294            }
295            "TIME" | "TIME.STATEMENT" | "TIME.TRANSACTION" => {
296                let t = frozen_now.time();
297                return Ok(Value::Temporal(TemporalValue::Time {
298                    nanos_since_midnight: time_to_nanos(&t),
299                    offset_seconds: 0,
300                }));
301            }
302            "LOCALTIME" | "LOCALTIME.STATEMENT" | "LOCALTIME.TRANSACTION" => {
303                let local = frozen_now.with_timezone(&chrono::Local).time();
304                return Ok(Value::Temporal(TemporalValue::LocalTime {
305                    nanos_since_midnight: time_to_nanos(&local),
306                }));
307            }
308            "DATETIME" | "DATETIME.STATEMENT" | "DATETIME.TRANSACTION" => {
309                return Ok(Value::Temporal(TemporalValue::DateTime {
310                    nanos_since_epoch: frozen_now.timestamp_nanos_opt().unwrap_or(0),
311                    offset_seconds: 0,
312                    timezone_name: None,
313                }));
314            }
315            "LOCALDATETIME" | "LOCALDATETIME.STATEMENT" | "LOCALDATETIME.TRANSACTION" => {
316                let local = frozen_now.with_timezone(&chrono::Local).naive_local();
317                let epoch = NaiveDateTime::new(
318                    NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(),
319                    NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
320                );
321                let nanos = local
322                    .signed_duration_since(epoch)
323                    .num_nanoseconds()
324                    .unwrap_or(0);
325                return Ok(Value::Temporal(TemporalValue::LocalDateTime {
326                    nanos_since_epoch: nanos,
327                }));
328            }
329            _ => {}
330        }
331    }
332    // Fall through to the regular eval for non-clock functions or functions with args
333    eval_datetime_function(name, args)
334}
335
336pub fn eval_datetime_function(name: &str, args: &[Value]) -> Result<Value> {
337    match name {
338        // Basic constructors
339        "DATE" => eval_date(args),
340        "TIME" => eval_time(args),
341        "DATETIME" => eval_datetime(args),
342        "LOCALDATETIME" => eval_localdatetime(args),
343        "LOCALTIME" => eval_localtime(args),
344        "DURATION" => eval_duration(args),
345
346        // Extraction functions
347        "YEAR" => eval_extract(args, Component::Year),
348        "MONTH" => eval_extract(args, Component::Month),
349        "DAY" => eval_extract(args, Component::Day),
350        "HOUR" => eval_extract(args, Component::Hour),
351        "MINUTE" => eval_extract(args, Component::Minute),
352        "SECOND" => eval_extract(args, Component::Second),
353
354        // Epoch functions
355        "DATETIME.FROMEPOCH" => eval_datetime_fromepoch(args),
356        "DATETIME.FROMEPOCHMILLIS" => eval_datetime_fromepochmillis(args),
357
358        // Truncate functions
359        "DATE.TRUNCATE" => eval_truncate("date", args),
360        "TIME.TRUNCATE" => eval_truncate("time", args),
361        "DATETIME.TRUNCATE" => eval_truncate("datetime", args),
362        "LOCALDATETIME.TRUNCATE" => eval_truncate("localdatetime", args),
363        "LOCALTIME.TRUNCATE" => eval_truncate("localtime", args),
364
365        // Transaction/statement/realtime functions (return current time)
366        "DATETIME.TRANSACTION" | "DATETIME.STATEMENT" | "DATETIME.REALTIME" => eval_datetime(args),
367        "DATE.TRANSACTION" | "DATE.STATEMENT" | "DATE.REALTIME" => eval_date(args),
368        "TIME.TRANSACTION" | "TIME.STATEMENT" | "TIME.REALTIME" => eval_time(args),
369        "LOCALTIME.TRANSACTION" | "LOCALTIME.STATEMENT" | "LOCALTIME.REALTIME" => {
370            eval_localtime(args)
371        }
372        "LOCALDATETIME.TRANSACTION" | "LOCALDATETIME.STATEMENT" | "LOCALDATETIME.REALTIME" => {
373            eval_localdatetime(args)
374        }
375
376        // Duration between functions
377        "DURATION.BETWEEN" => eval_duration_between(args),
378        "DURATION.INMONTHS" => eval_duration_in_months(args),
379        "DURATION.INDAYS" => eval_duration_in_days(args),
380        "DURATION.INSECONDS" => eval_duration_in_seconds(args),
381
382        // BTIC temporal interval constructor
383        "BTIC" => eval_btic(args),
384
385        _ => Err(anyhow!("Unknown datetime function: {}", name)),
386    }
387}
388
389/// Construct a BTIC temporal interval from a string literal.
390///
391/// Accepts ISO 8601 inspired formats via `uni_btic::parse::parse_btic_literal`:
392/// - `btic('1985')` → year interval
393/// - `btic('1985-03/2024-06')` → range with solidus
394/// - `btic('~1985')` → approximate certainty
395/// - `btic('2020-03/')` → ongoing (unbounded hi)
396/// - `btic('/')` → fully unbounded
397fn eval_btic(args: &[Value]) -> Result<Value> {
398    if args.is_empty() {
399        return Err(anyhow!("btic() requires exactly 1 argument"));
400    }
401    if args.len() > 1 {
402        return Err(anyhow!("btic() accepts 1 argument, got {}", args.len()));
403    }
404    match &args[0] {
405        Value::Null => Ok(Value::Null),
406        Value::String(s) => {
407            let btic = uni_btic::parse::parse_btic_literal(s).map_err(|e| {
408                anyhow!(
409                    "TypeError: InvalidArgumentValue - btic() failed to parse '{}': {}",
410                    s,
411                    e
412                )
413            })?;
414            Ok(Value::Temporal(uni_common::TemporalValue::Btic {
415                lo: btic.lo(),
416                hi: btic.hi(),
417                meta: btic.meta(),
418            }))
419        }
420        other => Err(anyhow!("btic() argument must be a string, got: {}", other)),
421    }
422}
423
424/// Check if value is a datetime string or temporal datetime.
425pub fn is_datetime_value(val: &Value) -> bool {
426    match val {
427        Value::Temporal(TemporalValue::DateTime { .. }) => true,
428        Value::String(s) => parse_datetime_utc(s).is_ok(),
429        _ => false,
430    }
431}
432
433/// Check if value is a date string or temporal date.
434pub fn is_date_value(val: &Value) -> bool {
435    match val {
436        Value::Temporal(TemporalValue::Date { .. }) => true,
437        Value::String(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok(),
438        _ => false,
439    }
440}
441
442/// Check if value is a duration (ISO 8601 string starting with 'P' or temporal duration).
443///
444/// Note: Numbers are NOT automatically treated as durations. The duration()
445/// function can accept numbers as microseconds, but arbitrary numbers in
446/// arithmetic expressions should not be interpreted as durations.
447pub fn is_duration_value(val: &Value) -> bool {
448    match val {
449        Value::Temporal(TemporalValue::Duration { .. }) => true,
450        Value::String(s) => is_duration_string(s),
451        _ => false,
452    }
453}
454
455/// Check if a value is a duration string OR an integer (microseconds).
456///
457/// This is used for temporal arithmetic where integers are implicitly treated
458/// as durations when paired with datetime/date values. For standalone type
459/// checking, use `is_duration_value` instead.
460pub fn is_duration_or_micros(val: &Value) -> bool {
461    is_duration_value(val) || matches!(val, Value::Int(_))
462}
463
464/// Convert a duration value (ISO 8601 string or i64 micros) to microseconds.
465pub fn duration_to_micros(val: &Value) -> Result<i64> {
466    match val {
467        Value::String(s) => {
468            let duration = parse_duration_to_cypher(s)?;
469            Ok(duration.to_micros())
470        }
471        Value::Int(i) => Ok(*i),
472        _ => Err(anyhow!("Expected duration value")),
473    }
474}
475
476/// Add duration (microseconds) to datetime.
477pub fn add_duration_to_datetime(dt_str: &str, micros: i64) -> Result<String> {
478    let dt = parse_datetime_utc(dt_str)?;
479    let result = dt + Duration::microseconds(micros);
480    Ok(result.to_rfc3339())
481}
482
483/// Add duration (microseconds) to date.
484pub fn add_duration_to_date(date_str: &str, micros: i64) -> Result<String> {
485    let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?;
486    let dt = date
487        .and_hms_opt(0, 0, 0)
488        .ok_or_else(|| anyhow!("Invalid date"))?;
489    let result = dt + Duration::microseconds(micros);
490    Ok(result.format("%Y-%m-%d").to_string())
491}
492
493/// Subtract two datetimes, return duration in microseconds.
494pub fn datetime_difference(dt1_str: &str, dt2_str: &str) -> Result<i64> {
495    let dt1 = parse_datetime_utc(dt1_str)?;
496    let dt2 = parse_datetime_utc(dt2_str)?;
497    dt1.signed_duration_since(dt2)
498        .num_microseconds()
499        .ok_or_else(|| anyhow!("Duration overflow"))
500}
501
502/// Parse a duration string to microseconds.
503///
504/// Supports ISO 8601 format (P1DT1H30M) and simple formats (1h30m, 90s, etc.)
505pub fn parse_duration_to_micros(s: &str) -> Result<i64> {
506    let s = s.trim();
507
508    // ISO 8601 format: P[n]Y[n]M[n]DT[n]H[n]M[n]S
509    if s.starts_with(['P', 'p']) {
510        return parse_iso8601_duration(s);
511    }
512
513    // Simple format: combinations of NdNhNmNs (e.g., "1d2h30m", "90s", "1h30m")
514    parse_simple_duration(s)
515}
516
517/// Parse a duration string to a CypherDuration with preserved components.
518pub fn parse_duration_to_cypher(s: &str) -> Result<CypherDuration> {
519    let s = s.trim();
520
521    // ISO 8601 format: P[n]Y[n]M[n]DT[n]H[n]M[n]S
522    if s.starts_with(['P', 'p']) {
523        return parse_iso8601_duration_cypher(s);
524    }
525
526    // Simple format: fall back to microseconds conversion
527    let micros = parse_simple_duration(s)?;
528    Ok(CypherDuration::from_micros(micros))
529}
530
531/// Parse date-time style ISO 8601 duration format (e.g., `P2012-02-02T14:37:21.545`).
532///
533/// Format: `PYYYY-MM-DDTHH:MM:SS.fff`
534fn parse_datetime_style_duration(s: &str) -> Result<CypherDuration> {
535    let body = &s[1..]; // Skip 'P'
536
537    // Split on 'T' for date and time parts
538    let (date_part, time_part) = if let Some(t_pos) = body.find('T') {
539        (&body[..t_pos], Some(&body[t_pos + 1..]))
540    } else {
541        (body, None)
542    };
543
544    // Parse date part: YYYY-MM-DD
545    let date_parts: Vec<&str> = date_part.split('-').collect();
546    if date_parts.len() != 3 {
547        return Err(anyhow!(
548            "Invalid date-time style duration date: {}",
549            date_part
550        ));
551    }
552    let years: i64 = date_parts[0]
553        .parse()
554        .map_err(|_| anyhow!("Invalid years"))?;
555    let month_val: i64 = date_parts[1]
556        .parse()
557        .map_err(|_| anyhow!("Invalid months"))?;
558    let day_val: i64 = date_parts[2].parse().map_err(|_| anyhow!("Invalid days"))?;
559
560    let months = years * 12 + month_val;
561    let days = day_val;
562
563    // Parse time part: HH:MM:SS.fff
564    let nanos = if let Some(tp) = time_part {
565        let time_parts: Vec<&str> = tp.split(':').collect();
566        if time_parts.len() != 3 {
567            return Err(anyhow!("Invalid date-time style duration time: {}", tp));
568        }
569        let hours: f64 = time_parts[0]
570            .parse()
571            .map_err(|_| anyhow!("Invalid hours"))?;
572        let minutes: f64 = time_parts[1]
573            .parse()
574            .map_err(|_| anyhow!("Invalid minutes"))?;
575        let seconds: f64 = time_parts[2]
576            .parse()
577            .map_err(|_| anyhow!("Invalid seconds"))?;
578        (hours * 3600.0 * NANOS_PER_SECOND as f64
579            + minutes * 60.0 * NANOS_PER_SECOND as f64
580            + seconds * NANOS_PER_SECOND as f64) as i64
581    } else {
582        0
583    };
584
585    Ok(CypherDuration::new(months, days, nanos))
586}
587
588/// Parse ISO 8601 duration format to CypherDuration (preserves month/day/time components).
589fn parse_iso8601_duration_cypher(s: &str) -> Result<CypherDuration> {
590    // Detect date-time style duration: after 'P', if char at position 4 is '-' and length >= 10
591    // e.g., P2012-02-02T14:37:21.545
592    if s.len() >= 11
593        && s.as_bytes().get(5) == Some(&b'-')
594        && s.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
595    {
596        return parse_datetime_style_duration(s);
597    }
598
599    let s = &s[1..]; // Skip 'P'
600    let mut months: i64 = 0;
601    let mut days: i64 = 0;
602    let mut nanos: i64 = 0;
603    let mut in_time_part = false;
604    let mut num_buf = String::new();
605
606    for c in s.chars() {
607        if c == 'T' || c == 't' {
608            in_time_part = true;
609            continue;
610        }
611
612        if c.is_ascii_digit() || c == '.' || c == '-' {
613            num_buf.push(c);
614        } else {
615            if num_buf.is_empty() {
616                continue;
617            }
618            let num: f64 = num_buf
619                .parse()
620                .map_err(|_| anyhow!("Invalid duration number"))?;
621            num_buf.clear();
622
623            match c {
624                'Y' | 'y' => {
625                    // Cascade: whole years → months, fractional years → via average Gregorian year
626                    let whole = num.trunc() as i64;
627                    let frac = num.fract();
628                    months += whole * 12;
629                    if frac != 0.0 {
630                        // Fractional year → fractional months → cascade
631                        let frac_months = frac * 12.0;
632                        let whole_frac_months = frac_months.trunc() as i64;
633                        let frac_frac_months = frac_months.fract();
634                        months += whole_frac_months;
635                        // Cascade remaining fractional months via average Gregorian month (2,629,746 seconds)
636                        let frac_secs = frac_frac_months * 2_629_746.0;
637                        let extra_days = (frac_secs / SECONDS_PER_DAY as f64).trunc() as i64;
638                        let remaining_secs =
639                            frac_secs - (extra_days as f64 * SECONDS_PER_DAY as f64);
640                        days += extra_days;
641                        nanos += (remaining_secs * NANOS_PER_SECOND as f64) as i64;
642                    }
643                }
644                'M' if !in_time_part => {
645                    // Cascade: whole months, fractional months → days + nanos via average Gregorian month
646                    let whole = num.trunc() as i64;
647                    let frac = num.fract();
648                    months += whole;
649                    if frac != 0.0 {
650                        let frac_secs = frac * 2_629_746.0;
651                        let extra_days = (frac_secs / SECONDS_PER_DAY as f64).trunc() as i64;
652                        let remaining_secs =
653                            frac_secs - (extra_days as f64 * SECONDS_PER_DAY as f64);
654                        days += extra_days;
655                        nanos += (remaining_secs * NANOS_PER_SECOND as f64) as i64;
656                    }
657                }
658                'W' | 'w' => {
659                    // Cascade: weeks to days, fractional days to nanos
660                    let total_days_f = num * 7.0;
661                    let whole = total_days_f.trunc() as i64;
662                    let frac = total_days_f.fract();
663                    days += whole;
664                    nanos += (frac * NANOS_PER_DAY as f64) as i64;
665                }
666                'D' | 'd' => {
667                    // Cascade: whole days, fractional days to nanos
668                    let whole = num.trunc() as i64;
669                    let frac = num.fract();
670                    days += whole;
671                    nanos += (frac * NANOS_PER_DAY as f64) as i64;
672                }
673                'H' | 'h' => nanos += (num * 3600.0 * NANOS_PER_SECOND as f64) as i64,
674                'M' | 'm' if in_time_part => nanos += (num * 60.0 * NANOS_PER_SECOND as f64) as i64,
675                'S' | 's' => nanos += (num * NANOS_PER_SECOND as f64) as i64,
676                _ => return Err(anyhow!("Invalid ISO 8601 duration designator: {}", c)),
677            }
678        }
679    }
680
681    Ok(CypherDuration::new(months, days, nanos))
682}
683
684// ============================================================================
685// Component Extraction
686// ============================================================================
687
688enum Component {
689    Year,
690    Month,
691    Day,
692    Hour,
693    Minute,
694    Second,
695}
696
697fn eval_extract(args: &[Value], component: Component) -> Result<Value> {
698    if args.len() != 1 {
699        return Err(anyhow!("Extract function requires 1 argument"));
700    }
701    match &args[0] {
702        Value::Temporal(tv) => {
703            let result = match component {
704                Component::Year => tv.year(),
705                Component::Month => tv.month(),
706                Component::Day => tv.day(),
707                Component::Hour => tv.hour(),
708                Component::Minute => tv.minute(),
709                Component::Second => tv.second(),
710            };
711            match result {
712                Some(v) => Ok(Value::Int(v)),
713                None => Err(anyhow!("Temporal value does not have requested component")),
714            }
715        }
716        Value::String(s) => {
717            // Try parsing as DateTime, then NaiveDateTime, then NaiveDate, then NaiveTime
718            if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
719                return Ok(Value::Int(extract_component(&dt, &component) as i64));
720            }
721            if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
722                return Ok(Value::Int(extract_component(&dt, &component) as i64));
723            }
724
725            match component {
726                Component::Year | Component::Month | Component::Day => {
727                    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
728                        return Ok(Value::Int(match component {
729                            Component::Year => d.year() as i64,
730                            Component::Month => d.month() as i64,
731                            Component::Day => d.day() as i64,
732                            _ => unreachable!(),
733                        }));
734                    }
735                }
736                Component::Hour | Component::Minute | Component::Second => {
737                    if let Ok(t) = NaiveTime::parse_from_str(s, "%H:%M:%S") {
738                        return Ok(Value::Int(match component {
739                            Component::Hour => t.hour() as i64,
740                            Component::Minute => t.minute() as i64,
741                            Component::Second => t.second() as i64,
742                            _ => unreachable!(),
743                        }));
744                    }
745                }
746            }
747
748            Err(anyhow!("Could not parse date/time string for extraction"))
749        }
750        Value::Null => Ok(Value::Null),
751        _ => Err(anyhow!(
752            "Extract function expects a temporal or string argument"
753        )),
754    }
755}
756
757fn extract_component<T: Datelike + Timelike>(dt: &T, component: &Component) -> i32 {
758    match component {
759        Component::Year => dt.year(),
760        Component::Month => dt.month() as i32,
761        Component::Day => dt.day() as i32,
762        Component::Hour => dt.hour() as i32,
763        Component::Minute => dt.minute() as i32,
764        Component::Second => dt.second() as i32,
765    }
766}
767
768// ============================================================================
769// Temporal Component Accessors (for property access on temporals)
770// ============================================================================
771
772/// Evaluate a temporal component accessor.
773///
774/// This handles property access on temporal values like `dt.quarter`, `dt.week`,
775/// `dt.dayOfWeek`, `dt.timezone`, etc.
776pub fn eval_temporal_accessor(temporal_str: &str, component: &str) -> Result<Value> {
777    let component_lower = component.to_lowercase();
778    match component_lower.as_str() {
779        // Basic date components (already handled by eval_extract but also here for consistency)
780        "year" => extract_year(temporal_str),
781        "month" => extract_month(temporal_str),
782        "day" => extract_day(temporal_str),
783        "hour" => extract_hour(temporal_str),
784        "minute" => extract_minute(temporal_str),
785        "second" => extract_second(temporal_str),
786
787        // Extended date components
788        "quarter" => extract_quarter(temporal_str),
789        "week" => extract_week(temporal_str),
790        "weekyear" => extract_week_year(temporal_str),
791        "ordinalday" => extract_ordinal_day(temporal_str),
792        "dayofweek" | "weekday" => extract_day_of_week(temporal_str),
793        "dayofquarter" => extract_day_of_quarter(temporal_str),
794
795        // Sub-second components
796        "millisecond" => extract_millisecond(temporal_str),
797        "microsecond" => extract_microsecond(temporal_str),
798        "nanosecond" => extract_nanosecond(temporal_str),
799
800        // Timezone components
801        "timezone" => extract_timezone_name_from_str(temporal_str),
802        "offset" => extract_offset_string(temporal_str),
803        "offsetminutes" => extract_offset_minutes(temporal_str),
804        "offsetseconds" => extract_offset_seconds(temporal_str),
805
806        // Epoch components
807        "epochseconds" => extract_epoch_seconds(temporal_str),
808        "epochmillis" => extract_epoch_millis(temporal_str),
809
810        _ => Err(anyhow!("Unknown temporal component: {}", component)),
811    }
812}
813
814/// Evaluate a temporal component accessor on a `Value`.
815///
816/// Handles `Value::Temporal` (converts to string representation then delegates),
817/// `Value::String` (direct string-based extraction), and `Value::Null`.
818pub fn eval_temporal_accessor_value(val: &Value, component: &str) -> Result<Value> {
819    match val {
820        Value::Null => Ok(Value::Null),
821        // Non-graph map property access can be translated through _temporal_property
822        // for accessor-like names such as `year`. For map values, preserve normal
823        // Cypher map semantics: treat it as key lookup.
824        Value::Map(map) => Ok(map.get(component).cloned().unwrap_or(Value::Null)),
825        Value::Temporal(tv) => {
826            // For offset-related accessors on temporal values, extract directly
827            // from the TemporalValue fields to avoid lossy string round-trip.
828            let comp_lower = component.to_lowercase();
829            match comp_lower.as_str() {
830                "timezone" => {
831                    return match tv {
832                        TemporalValue::DateTime {
833                            timezone_name,
834                            offset_seconds,
835                            ..
836                        } => Ok(match timezone_name {
837                            Some(name) => Value::String(name.clone()),
838                            None => Value::String(format_timezone_offset(*offset_seconds)),
839                        }),
840                        TemporalValue::Time { offset_seconds, .. } => {
841                            Ok(Value::String(format_timezone_offset(*offset_seconds)))
842                        }
843                        _ => Ok(Value::Null),
844                    };
845                }
846                "offset" => {
847                    return match tv {
848                        TemporalValue::DateTime { offset_seconds, .. }
849                        | TemporalValue::Time { offset_seconds, .. } => {
850                            Ok(Value::String(format_timezone_offset(*offset_seconds)))
851                        }
852                        _ => Ok(Value::Null),
853                    };
854                }
855                "offsetminutes" => {
856                    return match tv {
857                        TemporalValue::DateTime { offset_seconds, .. }
858                        | TemporalValue::Time { offset_seconds, .. } => {
859                            Ok(Value::Int((*offset_seconds / 60) as i64))
860                        }
861                        _ => Ok(Value::Null),
862                    };
863                }
864                "offsetseconds" => {
865                    return match tv {
866                        TemporalValue::DateTime { offset_seconds, .. }
867                        | TemporalValue::Time { offset_seconds, .. } => {
868                            Ok(Value::Int(*offset_seconds as i64))
869                        }
870                        _ => Ok(Value::Null),
871                    };
872                }
873                "epochseconds" => {
874                    return match tv {
875                        TemporalValue::DateTime {
876                            nanos_since_epoch, ..
877                        } => Ok(Value::Int(nanos_since_epoch / 1_000_000_000)),
878                        TemporalValue::LocalDateTime { nanos_since_epoch } => {
879                            Ok(Value::Int(nanos_since_epoch / 1_000_000_000))
880                        }
881                        TemporalValue::Date { days_since_epoch } => {
882                            Ok(Value::Int(*days_since_epoch as i64 * 86400))
883                        }
884                        _ => Ok(Value::Null),
885                    };
886                }
887                "epochmillis" => {
888                    return match tv {
889                        TemporalValue::DateTime {
890                            nanos_since_epoch, ..
891                        } => Ok(Value::Int(nanos_since_epoch / 1_000_000)),
892                        TemporalValue::LocalDateTime { nanos_since_epoch } => {
893                            Ok(Value::Int(nanos_since_epoch / 1_000_000))
894                        }
895                        TemporalValue::Date { days_since_epoch } => {
896                            Ok(Value::Int(*days_since_epoch as i64 * 86400 * 1000))
897                        }
898                        _ => Ok(Value::Null),
899                    };
900                }
901                _ => {}
902            }
903            // For all other accessors, convert to string and delegate
904            let temporal_str = tv.to_string();
905            eval_temporal_accessor(&temporal_str, component)
906        }
907        Value::String(s) => eval_temporal_accessor(s, component),
908        _ => Err(anyhow!(
909            "Cannot access temporal property '{}' on non-temporal value",
910            component
911        )),
912    }
913}
914
915/// Check if a property name is a valid temporal accessor.
916pub fn is_temporal_accessor(property: &str) -> bool {
917    let property_lower = property.to_lowercase();
918    matches!(
919        property_lower.as_str(),
920        "year"
921            | "month"
922            | "day"
923            | "hour"
924            | "minute"
925            | "second"
926            | "quarter"
927            | "week"
928            | "weekyear"
929            | "ordinalday"
930            | "dayofweek"
931            | "weekday"
932            | "dayofquarter"
933            | "millisecond"
934            | "microsecond"
935            | "nanosecond"
936            | "timezone"
937            | "offset"
938            | "offsetminutes"
939            | "offsetseconds"
940            | "epochseconds"
941            | "epochmillis"
942    )
943}
944
945/// Check if a string looks like a temporal value (date, time, datetime).
946pub fn is_temporal_string(s: &str) -> bool {
947    let bytes = s.as_bytes();
948    if bytes.len() < 8 {
949        return false;
950    }
951
952    // Date pattern: YYYY-MM-DD
953    (bytes.len() >= 10 && bytes[4] == b'-' && bytes[7] == b'-')
954    // Time pattern: HH:MM:SS
955    || (bytes[2] == b':' && bytes[5] == b':')
956    // Duration pattern: starts with P
957    || (bytes[0] == b'P' || bytes[0] == b'p')
958}
959
960/// Check if a string looks like a duration value.
961pub fn is_duration_string(s: &str) -> bool {
962    s.starts_with(['P', 'p'])
963}
964
965// Individual component extractors
966
967fn extract_date_component(s: &str, f: impl FnOnce(NaiveDate) -> i64) -> Result<Value> {
968    let (date, _, _) = parse_datetime_with_tz(s)?;
969    Ok(Value::Int(f(date)))
970}
971
972fn extract_time_component(s: &str, f: impl FnOnce(NaiveTime) -> i64) -> Result<Value> {
973    let (_, time, _) = parse_datetime_with_tz(s)?;
974    Ok(Value::Int(f(time)))
975}
976
977fn extract_year(s: &str) -> Result<Value> {
978    extract_date_component(s, |d| d.year() as i64)
979}
980
981fn extract_month(s: &str) -> Result<Value> {
982    extract_date_component(s, |d| d.month() as i64)
983}
984
985fn extract_day(s: &str) -> Result<Value> {
986    extract_date_component(s, |d| d.day() as i64)
987}
988
989fn extract_hour(s: &str) -> Result<Value> {
990    extract_time_component(s, |t| t.hour() as i64)
991}
992
993fn extract_minute(s: &str) -> Result<Value> {
994    extract_time_component(s, |t| t.minute() as i64)
995}
996
997fn extract_second(s: &str) -> Result<Value> {
998    extract_time_component(s, |t| t.second() as i64)
999}
1000
1001fn extract_quarter(s: &str) -> Result<Value> {
1002    extract_date_component(s, |d| ((d.month() - 1) / 3 + 1) as i64)
1003}
1004
1005fn extract_week(s: &str) -> Result<Value> {
1006    extract_date_component(s, |d| d.iso_week().week() as i64)
1007}
1008
1009fn extract_week_year(s: &str) -> Result<Value> {
1010    extract_date_component(s, |d| d.iso_week().year() as i64)
1011}
1012
1013fn extract_ordinal_day(s: &str) -> Result<Value> {
1014    extract_date_component(s, |d| d.ordinal() as i64)
1015}
1016
1017fn extract_day_of_week(s: &str) -> Result<Value> {
1018    // ISO weekday: Monday = 1, Sunday = 7
1019    extract_date_component(s, |d| (d.weekday().num_days_from_monday() + 1) as i64)
1020}
1021
1022fn extract_day_of_quarter(s: &str) -> Result<Value> {
1023    let (date, _, _) = parse_datetime_with_tz(s)?;
1024    let quarter = (date.month() - 1) / 3;
1025    let first_month_of_quarter = quarter * 3 + 1;
1026    let quarter_start = NaiveDate::from_ymd_opt(date.year(), first_month_of_quarter, 1)
1027        .ok_or_else(|| {
1028            anyhow!(
1029                "Invalid quarter start for year={}, month={}",
1030                date.year(),
1031                first_month_of_quarter
1032            )
1033        })?;
1034    let day_of_quarter = (date - quarter_start).num_days() + 1;
1035    Ok(Value::Int(day_of_quarter))
1036}
1037
1038fn extract_millisecond(s: &str) -> Result<Value> {
1039    extract_time_component(s, |t| (t.nanosecond() / 1_000_000) as i64)
1040}
1041
1042fn extract_microsecond(s: &str) -> Result<Value> {
1043    extract_time_component(s, |t| (t.nanosecond() / 1_000) as i64)
1044}
1045
1046fn extract_nanosecond(s: &str) -> Result<Value> {
1047    extract_time_component(s, |t| t.nanosecond() as i64)
1048}
1049
1050fn extract_timezone_name_from_str(s: &str) -> Result<Value> {
1051    let (_, _, tz_info) = parse_datetime_with_tz(s)?;
1052    match tz_info {
1053        Some(TimezoneInfo::Named(tz)) => Ok(Value::String(tz.name().to_string())),
1054        Some(TimezoneInfo::FixedOffset(offset)) => {
1055            // Format as offset string with optional seconds
1056            let secs = offset.local_minus_utc();
1057            Ok(Value::String(format_timezone_offset(secs)))
1058        }
1059        None => Ok(Value::Null),
1060    }
1061}
1062
1063fn extract_offset_string(s: &str) -> Result<Value> {
1064    let (date, time, tz_info) = parse_datetime_with_tz(s)?;
1065    match tz_info {
1066        Some(ref tz) => {
1067            let ndt = NaiveDateTime::new(date, time);
1068            let offset = tz.offset_for_local(&ndt)?;
1069            Ok(Value::String(format_timezone_offset(
1070                offset.local_minus_utc(),
1071            )))
1072        }
1073        None => Ok(Value::Null),
1074    }
1075}
1076
1077fn extract_offset_total_seconds(s: &str) -> Result<i32> {
1078    let (date, time, tz_info) = parse_datetime_with_tz(s)?;
1079    match tz_info {
1080        Some(ref tz) => {
1081            let ndt = NaiveDateTime::new(date, time);
1082            let offset = tz.offset_for_local(&ndt)?;
1083            Ok(offset.local_minus_utc())
1084        }
1085        None => Ok(0),
1086    }
1087}
1088
1089fn extract_offset_minutes(s: &str) -> Result<Value> {
1090    Ok(Value::Int((extract_offset_total_seconds(s)? / 60) as i64))
1091}
1092
1093fn extract_offset_seconds(s: &str) -> Result<Value> {
1094    Ok(Value::Int(extract_offset_total_seconds(s)? as i64))
1095}
1096
1097fn parse_as_utc(s: &str) -> Result<DateTime<Utc>> {
1098    let (date, time, tz_info) = parse_datetime_with_tz(s)?;
1099    let local_ndt = NaiveDateTime::new(date, time);
1100
1101    if let Some(tz) = tz_info {
1102        let offset = tz.offset_for_local(&local_ndt)?;
1103        let utc_ndt = local_ndt - Duration::seconds(offset.local_minus_utc() as i64);
1104        Ok(DateTime::<Utc>::from_naive_utc_and_offset(utc_ndt, Utc))
1105    } else {
1106        Ok(DateTime::<Utc>::from_naive_utc_and_offset(local_ndt, Utc))
1107    }
1108}
1109
1110fn extract_epoch_seconds(s: &str) -> Result<Value> {
1111    Ok(Value::Int(parse_as_utc(s)?.timestamp()))
1112}
1113
1114fn extract_epoch_millis(s: &str) -> Result<Value> {
1115    Ok(Value::Int(parse_as_utc(s)?.timestamp_millis()))
1116}
1117
1118// ============================================================================
1119// Duration Component Accessors
1120// ============================================================================
1121
1122/// Evaluate a duration component accessor using Euclidean division.
1123///
1124/// Uses `div_euclid()` / `rem_euclid()` so remainders are always non-negative,
1125/// matching the TCK expectations for negative durations.
1126pub fn eval_duration_accessor(duration_str: &str, component: &str) -> Result<Value> {
1127    let duration = parse_duration_to_cypher(duration_str)?;
1128    let component_lower = component.to_lowercase();
1129
1130    let total_months = duration.months;
1131    let total_nanos = duration.nanos;
1132    let total_secs = total_nanos.div_euclid(NANOS_PER_SECOND);
1133
1134    match component_lower.as_str() {
1135        // Total components (converted to that unit)
1136        "years" => Ok(Value::Int(total_months.div_euclid(12))),
1137        "quarters" => Ok(Value::Int(total_months.div_euclid(3))),
1138        "months" => Ok(Value::Int(total_months)),
1139        "weeks" => Ok(Value::Int(duration.days.div_euclid(7))),
1140        "days" => Ok(Value::Int(duration.days)),
1141        "hours" => Ok(Value::Int(total_secs.div_euclid(3600))),
1142        "minutes" => Ok(Value::Int(total_secs.div_euclid(60))),
1143        "seconds" => Ok(Value::Int(total_secs)),
1144        "milliseconds" => Ok(Value::Int(total_nanos.div_euclid(1_000_000))),
1145        "microseconds" => Ok(Value::Int(total_nanos.div_euclid(1_000))),
1146        "nanoseconds" => Ok(Value::Int(total_nanos)),
1147
1148        // "Of" accessors (remainder within larger unit) using Euclidean remainder
1149        "quartersofyear" => Ok(Value::Int(total_months.rem_euclid(12) / 3)),
1150        "monthsofquarter" => Ok(Value::Int(total_months.rem_euclid(3))),
1151        "monthsofyear" => Ok(Value::Int(total_months.rem_euclid(12))),
1152        "daysofweek" => Ok(Value::Int(duration.days.rem_euclid(7))),
1153        "hoursofday" => Ok(Value::Int(total_secs.div_euclid(3600).rem_euclid(24))),
1154        "minutesofhour" => Ok(Value::Int(total_secs.div_euclid(60).rem_euclid(60))),
1155        "secondsofminute" => Ok(Value::Int(total_secs.rem_euclid(60))),
1156        "millisecondsofsecond" => Ok(Value::Int(
1157            total_nanos.div_euclid(1_000_000).rem_euclid(1000),
1158        )),
1159        "microsecondsofsecond" => Ok(Value::Int(
1160            total_nanos.div_euclid(1_000).rem_euclid(1_000_000),
1161        )),
1162        "nanosecondsofsecond" => Ok(Value::Int(total_nanos.rem_euclid(NANOS_PER_SECOND))),
1163
1164        _ => Err(anyhow!("Unknown duration component: {}", component)),
1165    }
1166}
1167
1168/// Check if a property name is a valid duration accessor.
1169pub fn is_duration_accessor(property: &str) -> bool {
1170    let property_lower = property.to_lowercase();
1171    matches!(
1172        property_lower.as_str(),
1173        "years"
1174            | "quarters"
1175            | "months"
1176            | "weeks"
1177            | "days"
1178            | "hours"
1179            | "minutes"
1180            | "seconds"
1181            | "milliseconds"
1182            | "microseconds"
1183            | "nanoseconds"
1184            | "quartersofyear"
1185            | "monthsofquarter"
1186            | "monthsofyear"
1187            | "daysofweek"
1188            | "hoursofday"
1189            | "minutesofhour"
1190            | "secondsofminute"
1191            | "millisecondsofsecond"
1192            | "microsecondsofsecond"
1193            | "nanosecondsofsecond"
1194    )
1195}
1196
1197// ============================================================================
1198// Date Constructor
1199// ============================================================================
1200
1201fn eval_date(args: &[Value]) -> Result<Value> {
1202    if args.is_empty() {
1203        // Current date
1204        let now = Utc::now().date_naive();
1205        return Ok(Value::Temporal(TemporalValue::Date {
1206            days_since_epoch: date_to_days_since_epoch(&now),
1207        }));
1208    }
1209
1210    match &args[0] {
1211        Value::String(s) => {
1212            match parse_date_string(s) {
1213                Ok(date) => Ok(Value::Temporal(TemporalValue::Date {
1214                    days_since_epoch: date_to_days_since_epoch(&date),
1215                })),
1216                Err(e) => {
1217                    if parse_extended_date_string(s).is_some() {
1218                        // Out-of-range years cannot fit the current TemporalValue Date encoding.
1219                        Ok(Value::String(s.clone()))
1220                    } else {
1221                        Err(e)
1222                    }
1223                }
1224            }
1225        }
1226        Value::Temporal(TemporalValue::Date { .. }) => Ok(args[0].clone()),
1227        // Cross-type: extract date component from any temporal with a date
1228        Value::Temporal(tv) => {
1229            if let Some(date) = tv.to_date() {
1230                Ok(Value::Temporal(TemporalValue::Date {
1231                    days_since_epoch: date_to_days_since_epoch(&date),
1232                }))
1233            } else {
1234                Err(anyhow!("date(): temporal value has no date component"))
1235            }
1236        }
1237        Value::Map(map) => eval_date_from_map(map),
1238        Value::Null => Ok(Value::Null),
1239        _ => Err(anyhow!("date() expects a string or map argument")),
1240    }
1241}
1242
1243/// Convert a NaiveDate to days since Unix epoch.
1244fn date_to_days_since_epoch(date: &NaiveDate) -> i32 {
1245    let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
1246    (date.signed_duration_since(epoch)).num_days() as i32
1247}
1248
1249fn eval_date_from_map(map: &HashMap<String, Value>) -> Result<Value> {
1250    // Check if we have a 'date' field to copy from another date/datetime
1251    if let Some(dt_val) = map.get("date") {
1252        return eval_date_from_projection(map, dt_val);
1253    }
1254
1255    let date = build_date_from_map(map)?;
1256    Ok(Value::Temporal(TemporalValue::Date {
1257        days_since_epoch: date_to_days_since_epoch(&date),
1258    }))
1259}
1260
1261/// Handle date construction from projection (copying from another temporal value).
1262fn eval_date_from_projection(map: &HashMap<String, Value>, source: &Value) -> Result<Value> {
1263    let source_date = temporal_or_string_to_date(source)?;
1264    let date = build_date_from_projection(map, &source_date)?;
1265    Ok(Value::Temporal(TemporalValue::Date {
1266        days_since_epoch: date_to_days_since_epoch(&date),
1267    }))
1268}
1269
1270/// Extract a NaiveDate from a Value::Temporal or Value::String.
1271fn temporal_or_string_to_date(val: &Value) -> Result<NaiveDate> {
1272    match val {
1273        Value::Temporal(tv) => tv
1274            .to_date()
1275            .ok_or_else(|| anyhow!("Temporal value has no date component")),
1276        Value::String(s) => parse_datetime_with_tz(s).map(|(date, _, _)| date),
1277        _ => Err(anyhow!(
1278            "Expected temporal or string value for date extraction"
1279        )),
1280    }
1281}
1282
1283/// Build a NaiveDate from projection map, using source_date for defaults.
1284///
1285/// Supports multiple override modes:
1286/// - Week-based: override week, dayOfWeek (uses weekYear from source)
1287/// - Ordinal: override ordinalDay (uses year from source)
1288/// - Quarter: override quarter, dayOfQuarter (uses year from source)
1289/// - Calendar: override year, month, day (defaults from source)
1290fn build_date_from_projection(
1291    map: &HashMap<String, Value>,
1292    source_date: &NaiveDate,
1293) -> Result<NaiveDate> {
1294    // Week-based: {date: other, week: 2, dayOfWeek: 3}
1295    if map.contains_key("week") {
1296        let week_year = map
1297            .get("weekYear")
1298            .and_then(|v| v.as_i64())
1299            .map(|v| v as i32)
1300            .unwrap_or_else(|| source_date.iso_week().year());
1301        let week = map.get("week").and_then(|v| v.as_i64()).unwrap_or(1) as u32;
1302        let dow = map
1303            .get("dayOfWeek")
1304            .and_then(|v| v.as_i64())
1305            .unwrap_or_else(|| source_date.weekday().number_from_monday() as i64)
1306            as u32;
1307        return build_date_from_week(week_year, week, dow);
1308    }
1309
1310    // Ordinal: {date: other, ordinalDay: 202}
1311    if map.contains_key("ordinalDay") {
1312        let year = map
1313            .get("year")
1314            .and_then(|v| v.as_i64())
1315            .map(|v| v as i32)
1316            .unwrap_or(source_date.year());
1317        let ordinal = map
1318            .get("ordinalDay")
1319            .and_then(|v| v.as_i64())
1320            .unwrap_or(source_date.ordinal() as i64) as u32;
1321        return NaiveDate::from_yo_opt(year, ordinal)
1322            .ok_or_else(|| anyhow!("Invalid ordinal day: {} for year {}", ordinal, year));
1323    }
1324
1325    // Quarter: {date: other, quarter: 3, dayOfQuarter: 45}
1326    if map.contains_key("quarter") {
1327        let year = map
1328            .get("year")
1329            .and_then(|v| v.as_i64())
1330            .map(|v| v as i32)
1331            .unwrap_or(source_date.year());
1332        let quarter = map.get("quarter").and_then(|v| v.as_i64()).unwrap_or(1) as u32;
1333        let doq = map
1334            .get("dayOfQuarter")
1335            .and_then(|v| v.as_i64())
1336            .unwrap_or_else(|| day_of_quarter(source_date) as i64) as u32;
1337        return build_date_from_quarter(year, quarter, doq);
1338    }
1339
1340    // Calendar-based: year, month, day with defaults from source
1341    let year = map
1342        .get("year")
1343        .and_then(|v| v.as_i64())
1344        .map(|v| v as i32)
1345        .unwrap_or(source_date.year());
1346    let month = map
1347        .get("month")
1348        .and_then(|v| v.as_i64())
1349        .map(|v| v as u32)
1350        .unwrap_or(source_date.month());
1351    let day = map
1352        .get("day")
1353        .and_then(|v| v.as_i64())
1354        .map(|v| v as u32)
1355        .unwrap_or(source_date.day());
1356
1357    NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| anyhow!("Invalid date in projection"))
1358}
1359
1360/// Build a NaiveDate from map fields.
1361///
1362/// Supports multiple construction modes:
1363/// - Calendar: year, month, day
1364/// - Week-based: year, week, dayOfWeek
1365/// - Ordinal: year, ordinalDay
1366/// - Quarter: year, quarter, dayOfQuarter
1367fn build_date_from_map(map: &HashMap<String, Value>) -> Result<NaiveDate> {
1368    // Extract year (required for all date map constructors)
1369    let year = map
1370        .get("year")
1371        .and_then(|v| v.as_i64())
1372        .ok_or_else(|| anyhow!("date/datetime map requires 'year' field"))? as i32;
1373
1374    // Week-based: {year: 1984, week: 10, dayOfWeek: 3}
1375    if let Some(week) = map.get("week").and_then(|v| v.as_i64()) {
1376        let dow = map.get("dayOfWeek").and_then(|v| v.as_i64()).unwrap_or(1);
1377        return build_date_from_week(year, week as u32, dow as u32);
1378    }
1379
1380    // Ordinal: {year: 1984, ordinalDay: 202}
1381    if let Some(ordinal) = map.get("ordinalDay").and_then(|v| v.as_i64()) {
1382        return NaiveDate::from_yo_opt(year, ordinal as u32)
1383            .ok_or_else(|| anyhow!("Invalid ordinal day: {} for year {}", ordinal, year));
1384    }
1385
1386    // Quarter: {year: 1984, quarter: 3, dayOfQuarter: 45}
1387    if let Some(quarter) = map.get("quarter").and_then(|v| v.as_i64()) {
1388        let doq = map
1389            .get("dayOfQuarter")
1390            .and_then(|v| v.as_i64())
1391            .unwrap_or(1);
1392        return build_date_from_quarter(year, quarter as u32, doq as u32);
1393    }
1394
1395    // Calendar: standard year/month/day (with defaults)
1396    let month = map.get("month").and_then(|v| v.as_i64()).unwrap_or(1) as u32;
1397    let day = map.get("day").and_then(|v| v.as_i64()).unwrap_or(1) as u32;
1398
1399    NaiveDate::from_ymd_opt(year, month, day)
1400        .ok_or_else(|| anyhow!("Invalid date: year={}, month={}, day={}", year, month, day))
1401}
1402
1403/// Build date from ISO week number (returns NaiveDate).
1404fn build_date_from_week(year: i32, week: u32, day_of_week: u32) -> Result<NaiveDate> {
1405    if !(1..=53).contains(&week) {
1406        return Err(anyhow!("Week must be between 1 and 53"));
1407    }
1408    if !(1..=7).contains(&day_of_week) {
1409        return Err(anyhow!("Day of week must be between 1 and 7"));
1410    }
1411
1412    // Find January 4th of the given year (always in week 1)
1413    let jan4 =
1414        NaiveDate::from_ymd_opt(year, 1, 4).ok_or_else(|| anyhow!("Invalid year: {}", year))?;
1415
1416    // Find Monday of week 1
1417    let iso_week_day = jan4.weekday().num_days_from_monday();
1418    let week1_monday = jan4 - Duration::days(iso_week_day as i64);
1419
1420    // Calculate target date
1421    let days_offset = ((week - 1) * 7 + (day_of_week - 1)) as i64;
1422    Ok(week1_monday + Duration::days(days_offset))
1423}
1424
1425/// Compute the 1-based day-of-quarter for a given date.
1426fn day_of_quarter(date: &NaiveDate) -> u32 {
1427    let quarter_start_month = ((date.month() - 1) / 3) * 3 + 1;
1428    let quarter_start = NaiveDate::from_ymd_opt(date.year(), quarter_start_month, 1).unwrap();
1429    (date.signed_duration_since(quarter_start).num_days() + 1) as u32
1430}
1431
1432/// Build date from quarter and day of quarter (returns NaiveDate).
1433fn build_date_from_quarter(year: i32, quarter: u32, day_of_quarter: u32) -> Result<NaiveDate> {
1434    if !(1..=4).contains(&quarter) {
1435        return Err(anyhow!("Quarter must be between 1 and 4"));
1436    }
1437
1438    // First day of quarter
1439    let first_month = (quarter - 1) * 3 + 1;
1440    let quarter_start = NaiveDate::from_ymd_opt(year, first_month, 1)
1441        .ok_or_else(|| anyhow!("Invalid quarter start"))?;
1442
1443    // Add days (day_of_quarter is 1-based)
1444    let result = quarter_start + Duration::days((day_of_quarter - 1) as i64);
1445
1446    // Validate the result is still in the same quarter
1447    let result_quarter = (result.month() - 1) / 3 + 1;
1448    if result_quarter != quarter || result.year() != year {
1449        return Err(anyhow!(
1450            "Day {} is out of range for quarter {}",
1451            day_of_quarter,
1452            quarter
1453        ));
1454    }
1455
1456    Ok(result)
1457}
1458
1459fn parse_date_string(s: &str) -> Result<NaiveDate> {
1460    NaiveDate::parse_from_str(s, "%Y-%m-%d")
1461        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.date()))
1462        .or_else(|_| {
1463            // Try parsing RFC3339 datetime and extract date
1464            DateTime::parse_from_rfc3339(s).map(|dt| dt.date_naive())
1465        })
1466        // T-separated datetime formats (e.g., from localdatetime constructor)
1467        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f").map(|dt| dt.date()))
1468        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S").map(|dt| dt.date()))
1469        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M").map(|dt| dt.date()))
1470        // Compact ISO 8601 date formats (YYYYMMDD, YYYYDDD, YYYYWww, YYYYWwwD)
1471        .or_else(|e| try_parse_compact_date(s).ok_or(e))
1472        .or_else(|_| {
1473            // Fallback: use full datetime parser (handles offsets like +01:00, Z, named TZ)
1474            parse_datetime_with_tz(s).map(|(date, _, _)| date)
1475        })
1476        .map_err(|e| anyhow!("Invalid date format: {}", e))
1477}
1478
1479// ============================================================================
1480// Time Constructors
1481// ============================================================================
1482
1483fn eval_time(args: &[Value]) -> Result<Value> {
1484    if args.is_empty() {
1485        let now = Utc::now();
1486        let time = now.time();
1487        return Ok(Value::Temporal(TemporalValue::Time {
1488            nanos_since_midnight: time_to_nanos(&time),
1489            offset_seconds: 0,
1490        }));
1491    }
1492
1493    match &args[0] {
1494        Value::String(s) => {
1495            let (time, tz_info) = parse_time_string_with_tz(s)?;
1496            let offset = match tz_info {
1497                Some(ref info) => info
1498                    .offset_for_local(&NaiveDateTime::new(Utc::now().date_naive(), time))?
1499                    .local_minus_utc(),
1500                None => 0,
1501            };
1502            Ok(Value::Temporal(TemporalValue::Time {
1503                nanos_since_midnight: time_to_nanos(&time),
1504                offset_seconds: offset,
1505            }))
1506        }
1507        Value::Temporal(TemporalValue::Time { .. }) => Ok(args[0].clone()),
1508        // Cross-type: extract time + offset from any temporal
1509        Value::Temporal(tv) => {
1510            let time = tv
1511                .to_time()
1512                .ok_or_else(|| anyhow!("time(): temporal value has no time component"))?;
1513            let offset = match tv {
1514                TemporalValue::DateTime { offset_seconds, .. } => *offset_seconds,
1515                TemporalValue::Time { offset_seconds, .. } => *offset_seconds,
1516                _ => 0, // LocalTime, LocalDateTime, Date → UTC
1517            };
1518            Ok(Value::Temporal(TemporalValue::Time {
1519                nanos_since_midnight: time_to_nanos(&time),
1520                offset_seconds: offset,
1521            }))
1522        }
1523        Value::Map(map) => eval_time_from_map(map, true),
1524        Value::Null => Ok(Value::Null),
1525        _ => Err(anyhow!("time() expects a string or map argument")),
1526    }
1527}
1528
1529fn eval_localtime(args: &[Value]) -> Result<Value> {
1530    if args.is_empty() {
1531        let now = chrono::Local::now().time();
1532        return Ok(Value::Temporal(TemporalValue::LocalTime {
1533            nanos_since_midnight: time_to_nanos(&now),
1534        }));
1535    }
1536
1537    match &args[0] {
1538        Value::String(s) => {
1539            let time = parse_time_string(s)?;
1540            Ok(Value::Temporal(TemporalValue::LocalTime {
1541                nanos_since_midnight: time_to_nanos(&time),
1542            }))
1543        }
1544        Value::Temporal(TemporalValue::LocalTime { .. }) => Ok(args[0].clone()),
1545        // Cross-type: extract time from any temporal, strip timezone
1546        Value::Temporal(tv) => {
1547            let time = tv
1548                .to_time()
1549                .ok_or_else(|| anyhow!("localtime(): temporal value has no time component"))?;
1550            Ok(Value::Temporal(TemporalValue::LocalTime {
1551                nanos_since_midnight: time_to_nanos(&time),
1552            }))
1553        }
1554        Value::Map(map) => eval_time_from_map(map, false),
1555        Value::Null => Ok(Value::Null),
1556        _ => Err(anyhow!("localtime() expects a string or map argument")),
1557    }
1558}
1559
1560fn eval_time_from_map(map: &HashMap<String, Value>, with_timezone: bool) -> Result<Value> {
1561    // Check if we have a 'time' field to copy from another time/datetime
1562    if let Some(time_val) = map.get("time") {
1563        return eval_time_from_projection(map, time_val, with_timezone);
1564    }
1565
1566    let hour = map.get("hour").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
1567    let minute = map.get("minute").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
1568    let second = map.get("second").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
1569    let nanos = build_nanoseconds(map);
1570
1571    let time = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos).ok_or_else(|| {
1572        anyhow!(
1573            "Invalid time: hour={}, minute={}, second={}",
1574            hour,
1575            minute,
1576            second
1577        )
1578    })?;
1579
1580    let nanos = time_to_nanos(&time);
1581
1582    if with_timezone {
1583        // Handle timezone for time() if present
1584        let offset = if let Some(tz_str) = map.get("timezone").and_then(|v| v.as_str()) {
1585            parse_timezone_offset(tz_str)?
1586        } else {
1587            0
1588        };
1589        Ok(Value::Temporal(TemporalValue::Time {
1590            nanos_since_midnight: nanos,
1591            offset_seconds: offset,
1592        }))
1593    } else {
1594        Ok(Value::Temporal(TemporalValue::LocalTime {
1595            nanos_since_midnight: nanos,
1596        }))
1597    }
1598}
1599
1600/// Handle time construction from projection (copying from another temporal value).
1601fn eval_time_from_projection(
1602    map: &HashMap<String, Value>,
1603    source: &Value,
1604    with_timezone: bool,
1605) -> Result<Value> {
1606    // Extract source time and timezone from either Value::Temporal or Value::String
1607    let (source_time, source_offset) = match source {
1608        Value::Temporal(TemporalValue::Time {
1609            nanos_since_midnight,
1610            offset_seconds,
1611        }) => (nanos_to_time(*nanos_since_midnight), Some(*offset_seconds)),
1612        Value::Temporal(TemporalValue::LocalTime {
1613            nanos_since_midnight,
1614        }) => (nanos_to_time(*nanos_since_midnight), None),
1615        Value::Temporal(TemporalValue::DateTime {
1616            nanos_since_epoch,
1617            offset_seconds,
1618            ..
1619        }) => {
1620            // Extract time component from DateTime (use local time = UTC nanos + offset)
1621            let local_nanos = nanos_since_epoch + (*offset_seconds as i64) * 1_000_000_000;
1622            let dt = chrono::DateTime::from_timestamp_nanos(local_nanos);
1623            (dt.naive_utc().time(), Some(*offset_seconds))
1624        }
1625        Value::Temporal(TemporalValue::LocalDateTime { nanos_since_epoch }) => {
1626            let dt = chrono::DateTime::from_timestamp_nanos(*nanos_since_epoch);
1627            (dt.naive_utc().time(), None)
1628        }
1629        Value::Temporal(TemporalValue::Date { .. }) => {
1630            // Date has no time component, use midnight
1631            (NaiveTime::from_hms_opt(0, 0, 0).unwrap(), None)
1632        }
1633        Value::String(s) => {
1634            let (_, time, tz_info) = parse_datetime_with_tz(s)?;
1635            let offset = tz_info.as_ref().map(|tz| {
1636                let today = NaiveDate::from_ymd_opt(2000, 1, 1).unwrap();
1637                let ndt = NaiveDateTime::new(today, time);
1638                tz.offset_for_local(&ndt)
1639                    .map(|o| o.local_minus_utc())
1640                    .unwrap_or(0)
1641            });
1642            (time, offset)
1643        }
1644        _ => return Err(anyhow!("time field must be a string or temporal")),
1645    };
1646
1647    // Apply overrides from the map
1648    let hour = map
1649        .get("hour")
1650        .and_then(|v| v.as_i64())
1651        .map(|v| v as u32)
1652        .unwrap_or(source_time.hour());
1653    let minute = map
1654        .get("minute")
1655        .and_then(|v| v.as_i64())
1656        .map(|v| v as u32)
1657        .unwrap_or(source_time.minute());
1658    let second = map
1659        .get("second")
1660        .and_then(|v| v.as_i64())
1661        .map(|v| v as u32)
1662        .unwrap_or(source_time.second());
1663
1664    let nanos = if map.contains_key("millisecond")
1665        || map.contains_key("microsecond")
1666        || map.contains_key("nanosecond")
1667    {
1668        build_nanoseconds(map)
1669    } else {
1670        source_time.nanosecond()
1671    };
1672
1673    let time = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)
1674        .ok_or_else(|| anyhow!("Invalid time in projection"))?;
1675    let nanos = time_to_nanos(&time);
1676
1677    if with_timezone {
1678        if let Some(tz_str) = map.get("timezone").and_then(|v| v.as_str()) {
1679            let new_offset = parse_timezone_offset(tz_str)?;
1680            // If source has a timezone, perform timezone conversion:
1681            // UTC = local_time - source_offset; new_local = UTC + new_offset
1682            let converted_nanos = if let Some(src_offset) = source_offset {
1683                let utc_nanos = nanos - (src_offset as i64) * 1_000_000_000;
1684                let target_nanos = utc_nanos + (new_offset as i64) * 1_000_000_000;
1685                // Wrap around within a day
1686                target_nanos.rem_euclid(NANOS_PER_DAY)
1687            } else {
1688                // Source has no timezone (localtime/localdatetime): just assign
1689                nanos
1690            };
1691            Ok(Value::Temporal(TemporalValue::Time {
1692                nanos_since_midnight: converted_nanos,
1693                offset_seconds: new_offset,
1694            }))
1695        } else {
1696            let offset = source_offset.unwrap_or(0);
1697            Ok(Value::Temporal(TemporalValue::Time {
1698                nanos_since_midnight: nanos,
1699                offset_seconds: offset,
1700            }))
1701        }
1702    } else {
1703        Ok(Value::Temporal(TemporalValue::LocalTime {
1704            nanos_since_midnight: nanos,
1705        }))
1706    }
1707}
1708
1709fn parse_time_string(s: &str) -> Result<NaiveTime> {
1710    // Try various time formats
1711    NaiveTime::parse_from_str(s, "%H:%M:%S")
1712        .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S%.f"))
1713        .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S%.9f"))
1714        .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M"))
1715        // Try compact time formats (HHMMSS, HHMM, HH) before falling back to datetime parser,
1716        // since 4-digit strings like "2140" are ambiguous (year vs HHMM).
1717        .or_else(|e| try_parse_compact_time(s).ok_or(e))
1718        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.time()))
1719        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f").map(|dt| dt.time()))
1720        .or_else(|_| DateTime::parse_from_rfc3339(s).map(|dt| dt.time()))
1721        .or_else(|_| {
1722            // Fallback: use full datetime parser (handles offsets like +01:00, Z, named TZ)
1723            parse_datetime_with_tz(s).map(|(_, time, _)| time)
1724        })
1725        .map_err(|_| anyhow!("Invalid time format"))
1726}
1727
1728/// Parse a string as time with timezone, preferring time interpretation over date.
1729///
1730/// This is used by `eval_time` where the input is known to be a time value.
1731/// It handles ambiguous cases like "2140-02" (compact time 21:40 with offset -02:00)
1732/// that would otherwise be misinterpreted as a date (year 2140, February).
1733fn parse_time_string_with_tz(s: &str) -> Result<(NaiveTime, Option<TimezoneInfo>)> {
1734    // Strip bracketed timezone suffix
1735    let (datetime_part, tz_name) = if let Some(bracket_pos) = s.find('[') {
1736        let tz_name = s[bracket_pos + 1..s.len() - 1].to_string();
1737        (&s[..bracket_pos], Some(tz_name))
1738    } else {
1739        (s, None)
1740    };
1741
1742    // Try plain time formats first (no timezone)
1743    if let Ok(time) = try_parse_naive_time(datetime_part) {
1744        let tz_info = tz_name.map(|n| parse_timezone(&n)).transpose()?;
1745        return Ok((time, tz_info));
1746    }
1747
1748    // Try time with Z suffix
1749    if let Some(base) = datetime_part
1750        .strip_suffix('Z')
1751        .or_else(|| datetime_part.strip_suffix('z'))
1752        && let Ok(time) = try_parse_naive_time(base)
1753    {
1754        let utc_tz = TimezoneInfo::FixedOffset(FixedOffset::east_opt(0).unwrap());
1755        let tz_info = tz_name
1756            .map(|n| parse_timezone(&n))
1757            .transpose()?
1758            .or(Some(utc_tz));
1759        return Ok((time, tz_info));
1760    }
1761
1762    // Try splitting at + or - for timezone offset, preferring time interpretation
1763    if let Some(tz_pos) = datetime_part.rfind('+').or_else(|| {
1764        // For time strings, find the last '-' that's after at least HH (pos >= 2)
1765        datetime_part.rfind('-').filter(|&pos| pos >= 2)
1766    }) {
1767        let left_part = &datetime_part[..tz_pos];
1768        let tz_part = &datetime_part[tz_pos..];
1769
1770        if let Ok(time) = try_parse_naive_time(left_part) {
1771            let tz_info = if let Some(name) = tz_name {
1772                Some(parse_timezone(&name)?)
1773            } else {
1774                let offset = parse_timezone_offset(tz_part)?;
1775                let fo = FixedOffset::east_opt(offset)
1776                    .ok_or_else(|| anyhow!("Invalid timezone offset"))?;
1777                Some(TimezoneInfo::FixedOffset(fo))
1778            };
1779            return Ok((time, tz_info));
1780        }
1781    }
1782
1783    // Fall back to full datetime parser
1784    let (_, time, tz_info) = parse_datetime_with_tz(s)?;
1785    Ok((time, tz_info))
1786}
1787
1788fn build_nanoseconds(map: &HashMap<String, Value>) -> u32 {
1789    let millis = map.get("millisecond").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
1790    let micros = map.get("microsecond").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
1791    let nanos = map.get("nanosecond").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
1792
1793    millis * 1_000_000 + micros * 1_000 + nanos
1794}
1795
1796/// Build nanoseconds from map, preserving base value's sub-components when the
1797/// map doesn't override them. For example, if base_nanos encodes
1798/// millis=645, micros=876, nanos=123 and map only sets {nanosecond: 2},
1799/// the result preserves the millis and micros from the base.
1800fn build_nanoseconds_with_base(map: &HashMap<String, Value>, base_nanos: u32) -> u32 {
1801    let base_millis = base_nanos / 1_000_000;
1802    let base_micros = (base_nanos % 1_000_000) / 1_000;
1803    let base_nano_part = base_nanos % 1_000;
1804
1805    let millis = map
1806        .get("millisecond")
1807        .and_then(|v| v.as_i64())
1808        .unwrap_or(base_millis as i64) as u32;
1809    let micros = map
1810        .get("microsecond")
1811        .and_then(|v| v.as_i64())
1812        .unwrap_or(base_micros as i64) as u32;
1813    let nanos = map
1814        .get("nanosecond")
1815        .and_then(|v| v.as_i64())
1816        .unwrap_or(base_nano_part as i64) as u32;
1817
1818    millis * 1_000_000 + micros * 1_000 + nanos
1819}
1820
1821/// Format timezone offset with optional seconds (e.g., "+01:00" or "+02:05:59").
1822fn format_timezone_offset(offset_secs: i32) -> String {
1823    if offset_secs == 0 {
1824        "Z".to_string()
1825    } else {
1826        let hours = offset_secs / 3600;
1827        let remaining = offset_secs.abs() % 3600;
1828        let mins = remaining / 60;
1829        let secs = remaining % 60;
1830        if secs != 0 {
1831            format!("{:+03}:{:02}:{:02}", hours, mins, secs)
1832        } else {
1833            format!("{:+03}:{:02}", hours, mins)
1834        }
1835    }
1836}
1837
1838fn format_time_with_nanos(time: &NaiveTime) -> String {
1839    let nanos = time.nanosecond();
1840    let secs = time.second();
1841
1842    if nanos == 0 && secs == 0 {
1843        // Omit :00 seconds when they're zero
1844        time.format("%H:%M").to_string()
1845    } else if nanos == 0 {
1846        time.format("%H:%M:%S").to_string()
1847    } else if nanos.is_multiple_of(1_000_000) {
1848        // Milliseconds only
1849        time.format("%H:%M:%S%.3f").to_string()
1850    } else if nanos.is_multiple_of(1_000) {
1851        // Microseconds
1852        time.format("%H:%M:%S%.6f").to_string()
1853    } else {
1854        // Full nanoseconds
1855        time.format("%H:%M:%S%.9f").to_string()
1856    }
1857}
1858
1859fn parse_timezone_offset(tz: &str) -> Result<i32> {
1860    let tz = tz.trim();
1861    if tz == "Z" || tz == "z" {
1862        return Ok(0);
1863    }
1864
1865    // Must start with + or - and have at least HH (3 chars total)
1866    if tz.len() >= 3 && (tz.starts_with('+') || tz.starts_with('-')) {
1867        let sign = if tz.starts_with('-') { -1 } else { 1 };
1868        let hours: i32 = tz[1..3]
1869            .parse()
1870            .map_err(|_| anyhow!("Invalid timezone hours"))?;
1871
1872        let rest = &tz[3..];
1873        let (mins, secs) = if rest.is_empty() {
1874            // +HH (hours-only, e.g., -02)
1875            (0, 0)
1876        } else if let Some(after_colon) = rest.strip_prefix(':') {
1877            // Colon-separated: +HH:MM or +HH:MM:SS
1878            let mins: i32 = if after_colon.len() >= 2 {
1879                after_colon[..2]
1880                    .parse()
1881                    .map_err(|_| anyhow!("Invalid timezone minutes"))?
1882            } else {
1883                0
1884            };
1885            let secs: i32 = if after_colon.len() >= 5 && after_colon.as_bytes()[2] == b':' {
1886                // +HH:MM:SS
1887                after_colon[3..5]
1888                    .parse()
1889                    .map_err(|_| anyhow!("Invalid timezone seconds"))?
1890            } else {
1891                0
1892            };
1893            (mins, secs)
1894        } else {
1895            // Compact no-colon: +HHMM or +HHMMSS
1896            let mins: i32 = if rest.len() >= 2 {
1897                rest[..2]
1898                    .parse()
1899                    .map_err(|_| anyhow!("Invalid timezone minutes"))?
1900            } else {
1901                0
1902            };
1903            let secs: i32 = if rest.len() >= 4 {
1904                rest[2..4]
1905                    .parse()
1906                    .map_err(|_| anyhow!("Invalid timezone seconds"))?
1907            } else {
1908                0
1909            };
1910            (mins, secs)
1911        };
1912
1913        return Ok(sign * (hours * 3600 + mins * 60 + secs));
1914    }
1915
1916    Err(anyhow!("Unsupported timezone format: {}", tz))
1917}
1918
1919// ============================================================================
1920// Datetime Constructors
1921// ============================================================================
1922
1923fn eval_datetime(args: &[Value]) -> Result<Value> {
1924    if args.is_empty() {
1925        let now = Utc::now();
1926        return Ok(Value::Temporal(TemporalValue::DateTime {
1927            nanos_since_epoch: now.timestamp_nanos_opt().unwrap_or(0),
1928            offset_seconds: 0,
1929            timezone_name: None,
1930        }));
1931    }
1932
1933    match &args[0] {
1934        Value::String(s) => {
1935            let (date, time, tz_info) = parse_datetime_with_tz(s)?;
1936            let ndt = NaiveDateTime::new(date, time);
1937            let (offset_secs, tz_name) = match tz_info {
1938                Some(ref info) => {
1939                    let fo = info.offset_for_local(&ndt)?;
1940                    (fo.local_minus_utc(), info.name().map(|s| s.to_string()))
1941                }
1942                None => (0, None),
1943            };
1944            Ok(datetime_value_from_local_and_offset(
1945                &ndt,
1946                offset_secs,
1947                tz_name,
1948            ))
1949        }
1950        Value::Temporal(TemporalValue::DateTime { .. }) => Ok(args[0].clone()),
1951        // Cross-type: convert any temporal to datetime (add UTC timezone)
1952        Value::Temporal(tv) => {
1953            let date = tv.to_date().unwrap_or_else(|| Utc::now().date_naive());
1954            let time = tv
1955                .to_time()
1956                .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
1957            let ndt = NaiveDateTime::new(date, time);
1958            let offset = match tv {
1959                TemporalValue::Time { offset_seconds, .. } => *offset_seconds,
1960                _ => 0,
1961            };
1962            Ok(datetime_value_from_local_and_offset(&ndt, offset, None))
1963        }
1964        Value::Map(map) => eval_datetime_from_map(map, true),
1965        Value::Null => Ok(Value::Null),
1966        _ => Err(anyhow!("datetime() expects a string or map argument")),
1967    }
1968}
1969
1970fn eval_localdatetime(args: &[Value]) -> Result<Value> {
1971    if args.is_empty() {
1972        let now = chrono::Local::now().naive_local();
1973        let epoch = NaiveDateTime::new(
1974            NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(),
1975            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
1976        );
1977        let nanos = now
1978            .signed_duration_since(epoch)
1979            .num_nanoseconds()
1980            .unwrap_or(0);
1981        return Ok(Value::Temporal(TemporalValue::LocalDateTime {
1982            nanos_since_epoch: nanos,
1983        }));
1984    }
1985
1986    match &args[0] {
1987        Value::String(s) => {
1988            match parse_datetime_with_tz(s) {
1989                Ok((date, time, _)) => {
1990                    let ndt = NaiveDateTime::new(date, time);
1991                    Ok(localdatetime_value_from_naive(&ndt))
1992                }
1993                Err(e) => {
1994                    if parse_extended_localdatetime_string(s).is_some() {
1995                        // Out-of-range years cannot fit the current TemporalValue LocalDateTime encoding.
1996                        Ok(Value::String(s.clone()))
1997                    } else {
1998                        Err(e)
1999                    }
2000                }
2001            }
2002        }
2003        Value::Temporal(TemporalValue::LocalDateTime { .. }) => Ok(args[0].clone()),
2004        // Cross-type: extract date+time, strip timezone
2005        Value::Temporal(tv) => {
2006            let date = tv.to_date().unwrap_or_else(|| Utc::now().date_naive());
2007            let time = tv
2008                .to_time()
2009                .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
2010            let ndt = NaiveDateTime::new(date, time);
2011            Ok(localdatetime_value_from_naive(&ndt))
2012        }
2013        Value::Map(map) => eval_datetime_from_map(map, false),
2014        Value::Null => Ok(Value::Null),
2015        _ => Err(anyhow!("localdatetime() expects a string or map argument")),
2016    }
2017}
2018
2019/// Extract time and optional timezone info from a Value (temporal or string).
2020fn extract_time_and_tz_from_value(val: &Value) -> Result<(NaiveTime, Option<TimezoneInfo>)> {
2021    match val {
2022        Value::Temporal(tv) => {
2023            let time = tv
2024                .to_time()
2025                .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
2026            let tz = match tv {
2027                TemporalValue::DateTime {
2028                    offset_seconds,
2029                    timezone_name,
2030                    ..
2031                } => {
2032                    if let Some(name) = timezone_name {
2033                        Some(parse_timezone(name)?)
2034                    } else {
2035                        let fo = FixedOffset::east_opt(*offset_seconds)
2036                            .ok_or_else(|| anyhow!("Invalid offset"))?;
2037                        Some(TimezoneInfo::FixedOffset(fo))
2038                    }
2039                }
2040                TemporalValue::Time { offset_seconds, .. } => {
2041                    let fo = FixedOffset::east_opt(*offset_seconds)
2042                        .ok_or_else(|| anyhow!("Invalid offset"))?;
2043                    Some(TimezoneInfo::FixedOffset(fo))
2044                }
2045                _ => None,
2046            };
2047            Ok((time, tz))
2048        }
2049        Value::String(s) => {
2050            let (_, time, tz_info) = parse_datetime_with_tz(s)?;
2051            Ok((time, tz_info))
2052        }
2053        _ => Err(anyhow!("time must be a string or temporal")),
2054    }
2055}
2056
2057/// Convert NaiveDateTime to nanoseconds since Unix epoch.
2058/// Returns None when the value is outside i64 nanosecond range.
2059fn naive_datetime_to_nanos(ndt: &NaiveDateTime) -> Option<i64> {
2060    let epoch = NaiveDateTime::new(
2061        NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(),
2062        NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
2063    );
2064    ndt.signed_duration_since(epoch).num_nanoseconds()
2065}
2066
2067fn localdatetime_value_from_naive(ndt: &NaiveDateTime) -> Value {
2068    if let Some(nanos) = naive_datetime_to_nanos(ndt) {
2069        Value::Temporal(TemporalValue::LocalDateTime {
2070            nanos_since_epoch: nanos,
2071        })
2072    } else {
2073        Value::String(format_naive_datetime(ndt))
2074    }
2075}
2076
2077fn datetime_value_from_local_and_offset(
2078    local_ndt: &NaiveDateTime,
2079    offset_seconds: i32,
2080    timezone_name: Option<String>,
2081) -> Value {
2082    let utc_ndt = *local_ndt - Duration::seconds(offset_seconds as i64);
2083    let utc_dt = DateTime::<Utc>::from_naive_utc_and_offset(utc_ndt, Utc);
2084
2085    if let Some(nanos) = utc_dt.timestamp_nanos_opt() {
2086        Value::Temporal(TemporalValue::DateTime {
2087            nanos_since_epoch: nanos,
2088            offset_seconds,
2089            timezone_name,
2090        })
2091    } else {
2092        let rendered = if let Some(offset) = FixedOffset::east_opt(offset_seconds) {
2093            if let Some(dt) = offset.from_local_datetime(local_ndt).single() {
2094                format_datetime_with_offset_and_tz(&dt, timezone_name.as_deref())
2095            } else {
2096                let base = format!(
2097                    "{}{}",
2098                    format_naive_datetime(local_ndt),
2099                    format_timezone_offset(offset_seconds)
2100                );
2101                if let Some(name) = timezone_name.as_deref() {
2102                    format!("{base}[{name}]")
2103                } else {
2104                    base
2105                }
2106            }
2107        } else {
2108            let base = format!(
2109                "{}{}",
2110                format_naive_datetime(local_ndt),
2111                format_timezone_offset(offset_seconds)
2112            );
2113            if let Some(name) = timezone_name.as_deref() {
2114                format!("{base}[{name}]")
2115            } else {
2116                base
2117            }
2118        };
2119        Value::String(rendered)
2120    }
2121}
2122
2123fn eval_datetime_from_map(map: &HashMap<String, Value>, with_timezone: bool) -> Result<Value> {
2124    // Check if we have a 'datetime' field to copy from another datetime
2125    if let Some(dt_val) = map.get("datetime") {
2126        return eval_datetime_from_projection(map, dt_val, with_timezone);
2127    }
2128
2129    // When both 'date' and 'time' keys are present, combine them
2130    if let (Some(date_val), Some(time_val)) = (map.get("date"), map.get("time")) {
2131        return eval_datetime_from_date_and_time(map, date_val, time_val, with_timezone);
2132    }
2133
2134    // date-only projection: date from source, time from explicit map fields.
2135    // Unlike `datetime` projection, we do NOT inherit timezone from the date source —
2136    // it defaults to UTC unless an explicit `timezone` key is present.
2137    if let Some(date_val) = map.get("date") {
2138        let source_date = temporal_or_string_to_date(date_val)?;
2139        let date = build_date_from_projection(map, &source_date)?;
2140        let hour = map.get("hour").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
2141        let minute = map.get("minute").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
2142        let second = map.get("second").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
2143        let nanos = build_nanoseconds(map);
2144        let time = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)
2145            .ok_or_else(|| anyhow!("Invalid time in datetime map"))?;
2146        let ndt = NaiveDateTime::new(date, time);
2147
2148        if with_timezone {
2149            let (offset_secs, tz_name) =
2150                if let Some(tz_str) = map.get("timezone").and_then(|v| v.as_str()) {
2151                    let tz_info = parse_timezone(tz_str)?;
2152                    let offset = tz_info.offset_for_local(&ndt)?;
2153                    (
2154                        offset.local_minus_utc(),
2155                        tz_info.name().map(|s| s.to_string()),
2156                    )
2157                } else {
2158                    (0, None) // Default to UTC, not source tz
2159                };
2160
2161            return Ok(datetime_value_from_local_and_offset(
2162                &ndt,
2163                offset_secs,
2164                tz_name,
2165            ));
2166        } else {
2167            return Ok(localdatetime_value_from_naive(&ndt));
2168        }
2169    }
2170
2171    // Build time part: if 'time' key is present, extract from temporal/string;
2172    // otherwise build from explicit hour/minute/second fields.
2173    let (time, source_tz) = if let Some(time_val) = map.get("time") {
2174        let (t, tz) = extract_time_and_tz_from_value(time_val)?;
2175        // Apply overrides from map (hour, minute, second, etc.)
2176        let hour = map
2177            .get("hour")
2178            .and_then(|v| v.as_i64())
2179            .map(|v| v as u32)
2180            .unwrap_or(t.hour());
2181        let minute = map
2182            .get("minute")
2183            .and_then(|v| v.as_i64())
2184            .map(|v| v as u32)
2185            .unwrap_or(t.minute());
2186        let second = map
2187            .get("second")
2188            .and_then(|v| v.as_i64())
2189            .map(|v| v as u32)
2190            .unwrap_or(t.second());
2191        let nanos = if map.contains_key("millisecond")
2192            || map.contains_key("microsecond")
2193            || map.contains_key("nanosecond")
2194        {
2195            build_nanoseconds(map)
2196        } else {
2197            t.nanosecond()
2198        };
2199        let resolved_time = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)
2200            .ok_or_else(|| anyhow!("Invalid time in datetime map"))?;
2201        (resolved_time, tz)
2202    } else {
2203        let hour = map.get("hour").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
2204        let minute = map.get("minute").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
2205        let second = map.get("second").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
2206        let nanos = build_nanoseconds(map);
2207        let t = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)
2208            .ok_or_else(|| anyhow!("Invalid time in datetime map"))?;
2209        (t, None::<TimezoneInfo>)
2210    };
2211
2212    // Build date part - support multiple construction modes
2213    let date = build_date_from_map(map)?;
2214
2215    let ndt = NaiveDateTime::new(date, time);
2216
2217    if with_timezone {
2218        // Handle timezone: explicit > from time source > UTC default
2219        // When source has a timezone and a different explicit timezone is given,
2220        // perform timezone conversion (source_local → UTC → target_local).
2221        if let Some(tz_str) = map.get("timezone").and_then(|v| v.as_str()) {
2222            let tz_info = parse_timezone(tz_str)?;
2223            if let Some(ref src_tz) = source_tz {
2224                // Timezone CONVERSION: source local → UTC → target local
2225                let src_offset = src_tz.offset_for_local(&ndt)?;
2226                let utc_ndt = ndt - Duration::seconds(src_offset.local_minus_utc() as i64);
2227                let target_offset = tz_info.offset_for_utc(&utc_ndt);
2228                let offset_secs = target_offset.local_minus_utc();
2229                let tz_name = tz_info.name().map(|s| s.to_string());
2230                let target_local_ndt = utc_ndt + Duration::seconds(offset_secs as i64);
2231                Ok(datetime_value_from_local_and_offset(
2232                    &target_local_ndt,
2233                    offset_secs,
2234                    tz_name,
2235                ))
2236            } else {
2237                // Source has no timezone: just assign target timezone
2238                let offset = tz_info.offset_for_local(&ndt)?;
2239                let offset_secs = offset.local_minus_utc();
2240                let tz_name = tz_info.name().map(|s| s.to_string());
2241                Ok(datetime_value_from_local_and_offset(
2242                    &ndt,
2243                    offset_secs,
2244                    tz_name,
2245                ))
2246            }
2247        } else if let Some(ref tz) = source_tz {
2248            let offset = tz.offset_for_local(&ndt)?;
2249            let offset_secs = offset.local_minus_utc();
2250            let tz_name = tz.name().map(|s| s.to_string());
2251            Ok(datetime_value_from_local_and_offset(
2252                &ndt,
2253                offset_secs,
2254                tz_name,
2255            ))
2256        } else {
2257            // No timezone at all: default to UTC
2258            Ok(datetime_value_from_local_and_offset(&ndt, 0, None))
2259        }
2260    } else {
2261        // localdatetime - no timezone
2262        Ok(localdatetime_value_from_naive(&ndt))
2263    }
2264}
2265
2266/// Handle datetime construction from separate date and time sources.
2267///
2268/// Cypher: `datetime({date: dateVal, time: timeVal, ...overrides})`
2269/// Extracts date component from dateVal, time + tz from timeVal, then applies overrides.
2270fn eval_datetime_from_date_and_time(
2271    map: &HashMap<String, Value>,
2272    date_val: &Value,
2273    time_val: &Value,
2274    with_timezone: bool,
2275) -> Result<Value> {
2276    let source_date = temporal_or_string_to_date(date_val)?;
2277    let (source_time, source_tz) = match time_val {
2278        Value::Temporal(tv) => {
2279            let time = tv
2280                .to_time()
2281                .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
2282            let tz = match tv {
2283                TemporalValue::DateTime {
2284                    offset_seconds,
2285                    timezone_name,
2286                    ..
2287                } => {
2288                    if let Some(name) = timezone_name {
2289                        Some(parse_timezone(name)?)
2290                    } else {
2291                        let fo = FixedOffset::east_opt(*offset_seconds)
2292                            .ok_or_else(|| anyhow!("Invalid offset"))?;
2293                        Some(TimezoneInfo::FixedOffset(fo))
2294                    }
2295                }
2296                TemporalValue::Time { offset_seconds, .. } => {
2297                    let fo = FixedOffset::east_opt(*offset_seconds)
2298                        .ok_or_else(|| anyhow!("Invalid offset"))?;
2299                    Some(TimezoneInfo::FixedOffset(fo))
2300                }
2301                _ => None,
2302            };
2303            (time, tz)
2304        }
2305        Value::String(s) => {
2306            let (_, time, tz_info) = parse_datetime_with_tz(s)?;
2307            (time, tz_info)
2308        }
2309        _ => return Err(anyhow!("time field must be a string or temporal")),
2310    };
2311
2312    // Build date from projection overrides
2313    let date = build_date_from_projection(map, &source_date)?;
2314
2315    // Build time from overrides
2316    let hour = map
2317        .get("hour")
2318        .and_then(|v| v.as_i64())
2319        .map(|v| v as u32)
2320        .unwrap_or(source_time.hour());
2321    let minute = map
2322        .get("minute")
2323        .and_then(|v| v.as_i64())
2324        .map(|v| v as u32)
2325        .unwrap_or(source_time.minute());
2326    let second = map
2327        .get("second")
2328        .and_then(|v| v.as_i64())
2329        .map(|v| v as u32)
2330        .unwrap_or(source_time.second());
2331
2332    let nanos = if map.contains_key("millisecond")
2333        || map.contains_key("microsecond")
2334        || map.contains_key("nanosecond")
2335    {
2336        build_nanoseconds(map)
2337    } else {
2338        source_time.nanosecond()
2339    };
2340
2341    let time = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)
2342        .ok_or_else(|| anyhow!("Invalid time in datetime(date+time) projection"))?;
2343
2344    let ndt = NaiveDateTime::new(date, time);
2345
2346    if with_timezone {
2347        if let Some(tz_str) = map.get("timezone").and_then(|v| v.as_str()) {
2348            let tz_info = parse_timezone(tz_str)?;
2349            if let Some(ref src_tz) = source_tz {
2350                // Timezone CONVERSION: source local → UTC → target local
2351                let src_offset = src_tz.offset_for_local(&ndt)?;
2352                let utc_ndt = ndt - Duration::seconds(src_offset.local_minus_utc() as i64);
2353                let target_offset = tz_info.offset_for_utc(&utc_ndt);
2354                let offset_secs = target_offset.local_minus_utc();
2355                let tz_name = tz_info.name().map(|s| s.to_string());
2356                let target_local_ndt = utc_ndt + Duration::seconds(offset_secs as i64);
2357                Ok(datetime_value_from_local_and_offset(
2358                    &target_local_ndt,
2359                    offset_secs,
2360                    tz_name,
2361                ))
2362            } else {
2363                // Source has no timezone: just assign target timezone
2364                let offset = tz_info.offset_for_local(&ndt)?;
2365                let offset_secs = offset.local_minus_utc();
2366                let tz_name = tz_info.name().map(|s| s.to_string());
2367                Ok(datetime_value_from_local_and_offset(
2368                    &ndt,
2369                    offset_secs,
2370                    tz_name,
2371                ))
2372            }
2373        } else if let Some(ref tz) = source_tz {
2374            let offset = tz.offset_for_local(&ndt)?;
2375            let offset_secs = offset.local_minus_utc();
2376            let tz_name = tz.name().map(|s| s.to_string());
2377            Ok(datetime_value_from_local_and_offset(
2378                &ndt,
2379                offset_secs,
2380                tz_name,
2381            ))
2382        } else {
2383            // No timezone at all: default to UTC
2384            Ok(datetime_value_from_local_and_offset(&ndt, 0, None))
2385        }
2386    } else {
2387        Ok(localdatetime_value_from_naive(&ndt))
2388    }
2389}
2390
2391/// Handle datetime construction from projection (copying from another temporal value).
2392fn eval_datetime_from_projection(
2393    map: &HashMap<String, Value>,
2394    source: &Value,
2395    with_timezone: bool,
2396) -> Result<Value> {
2397    // Extract source components from either Value::Temporal or Value::String
2398    let (source_date, source_time, source_tz) = temporal_or_string_to_components(source)?;
2399
2400    // Build date portion using shared helper
2401    let date = build_date_from_projection(map, &source_date)?;
2402
2403    // Build time portion
2404    let hour = map
2405        .get("hour")
2406        .and_then(|v| v.as_i64())
2407        .map(|v| v as u32)
2408        .unwrap_or(source_time.hour());
2409    let minute = map
2410        .get("minute")
2411        .and_then(|v| v.as_i64())
2412        .map(|v| v as u32)
2413        .unwrap_or(source_time.minute());
2414    let second = map
2415        .get("second")
2416        .and_then(|v| v.as_i64())
2417        .map(|v| v as u32)
2418        .unwrap_or(source_time.second());
2419
2420    // Sub-seconds are inherited from source unless explicitly overridden.
2421    // When constructing via `datetime` key, overriding second/minute/hour
2422    // still preserves sub-seconds from the source (per TCK Temporal3).
2423    let nanos = if map.contains_key("millisecond")
2424        || map.contains_key("microsecond")
2425        || map.contains_key("nanosecond")
2426    {
2427        build_nanoseconds(map)
2428    } else {
2429        source_time.nanosecond()
2430    };
2431
2432    let time = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)
2433        .ok_or_else(|| anyhow!("Invalid time in projection"))?;
2434
2435    let ndt = NaiveDateTime::new(date, time);
2436
2437    if with_timezone {
2438        if let Some(tz_str) = map.get("timezone").and_then(|v| v.as_str()) {
2439            let tz_info = parse_timezone(tz_str)?;
2440            if let Some(ref src_tz) = source_tz {
2441                // Timezone CONVERSION: source local → UTC → target local
2442                let src_offset = src_tz.offset_for_local(&ndt)?;
2443                let utc_ndt = ndt - Duration::seconds(src_offset.local_minus_utc() as i64);
2444                let target_offset = tz_info.offset_for_utc(&utc_ndt);
2445                let offset_secs = target_offset.local_minus_utc();
2446                let tz_name = tz_info.name().map(|s| s.to_string());
2447                let target_local_ndt = utc_ndt + Duration::seconds(offset_secs as i64);
2448                Ok(datetime_value_from_local_and_offset(
2449                    &target_local_ndt,
2450                    offset_secs,
2451                    tz_name,
2452                ))
2453            } else {
2454                // Source has no timezone: just assign
2455                let offset = tz_info.offset_for_local(&ndt)?;
2456                let offset_secs = offset.local_minus_utc();
2457                let tz_name = tz_info.name().map(|s| s.to_string());
2458                Ok(datetime_value_from_local_and_offset(
2459                    &ndt,
2460                    offset_secs,
2461                    tz_name,
2462                ))
2463            }
2464        } else if let Some(ref tz) = source_tz {
2465            let offset = tz.offset_for_local(&ndt)?;
2466            let offset_secs = offset.local_minus_utc();
2467            let tz_name = tz.name().map(|s| s.to_string());
2468            Ok(datetime_value_from_local_and_offset(
2469                &ndt,
2470                offset_secs,
2471                tz_name,
2472            ))
2473        } else {
2474            // No timezone: default to UTC
2475            Ok(datetime_value_from_local_and_offset(&ndt, 0, None))
2476        }
2477    } else {
2478        Ok(localdatetime_value_from_naive(&ndt))
2479    }
2480}
2481
2482/// Extract date, time, and timezone from either Value::Temporal or Value::String.
2483fn temporal_or_string_to_components(
2484    val: &Value,
2485) -> Result<(NaiveDate, NaiveTime, Option<TimezoneInfo>)> {
2486    match val {
2487        Value::Temporal(tv) => {
2488            let date = tv.to_date().unwrap_or_else(|| Utc::now().date_naive());
2489            let time = tv
2490                .to_time()
2491                .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
2492            let tz_info = match tv {
2493                TemporalValue::DateTime {
2494                    offset_seconds,
2495                    timezone_name,
2496                    ..
2497                } => {
2498                    if let Some(name) = timezone_name {
2499                        Some(parse_timezone(name)?)
2500                    } else {
2501                        let fo = FixedOffset::east_opt(*offset_seconds)
2502                            .ok_or_else(|| anyhow!("Invalid offset"))?;
2503                        Some(TimezoneInfo::FixedOffset(fo))
2504                    }
2505                }
2506                TemporalValue::Time { offset_seconds, .. } => {
2507                    let fo = FixedOffset::east_opt(*offset_seconds)
2508                        .ok_or_else(|| anyhow!("Invalid offset"))?;
2509                    Some(TimezoneInfo::FixedOffset(fo))
2510                }
2511                _ => None,
2512            };
2513            Ok((date, time, tz_info))
2514        }
2515        Value::String(s) => parse_datetime_with_tz(s),
2516        _ => Err(anyhow!("Expected temporal or string value")),
2517    }
2518}
2519
2520/// Convert a 1-based ISO weekday number (1=Mon, 7=Sun) to a `chrono::Weekday`.
2521fn iso_weekday(d: u32) -> Option<Weekday> {
2522    match d {
2523        1 => Some(Weekday::Mon),
2524        2 => Some(Weekday::Tue),
2525        3 => Some(Weekday::Wed),
2526        4 => Some(Weekday::Thu),
2527        5 => Some(Weekday::Fri),
2528        6 => Some(Weekday::Sat),
2529        7 => Some(Weekday::Sun),
2530        _ => None,
2531    }
2532}
2533
2534/// Try parsing an ISO 8601 date string (compact or with separators).
2535///
2536/// Supports:
2537/// - `YYYYMMDD` (8 digits, e.g., `19840711` -> 1984-07-11)
2538/// - `YYYYDDD`  (7 digits, e.g., `1984183` -> ordinal day 183 of 1984)
2539/// - `YYYYMM`   (6 digits, e.g., `201507` -> 2015-07-01)
2540/// - `YYYY`     (4 digits, e.g., `2015` -> 2015-01-01)
2541/// - `YYYYWww`  (e.g., `1984W30` -> Monday of ISO week 30 of 1984)
2542/// - `YYYYWwwD` (e.g., `1984W305` -> day 5 of ISO week 30 of 1984)
2543/// - `YYYY-Www-D` / `YYYY-Www` (separator ISO week dates)
2544/// - `YYYY-DDD` (separator ordinal date)
2545/// - `YYYY-MM`  (separator year-month)
2546fn try_parse_compact_date(s: &str) -> Option<NaiveDate> {
2547    // 1. Separator ISO week dates: YYYY-Www-D (10 chars) or YYYY-Www (8 chars)
2548    if let Some(w_pos) = s.find("-W") {
2549        if w_pos == 4 {
2550            let year: i32 = s[..4].parse().ok()?;
2551            let after_w = &s[w_pos + 2..]; // skip "-W"
2552            // YYYY-Www-D (exactly 4 chars after W: "ww-D")
2553            if after_w.len() == 4 && after_w.as_bytes()[2] == b'-' {
2554                let week: u32 = after_w[..2].parse().ok()?;
2555                let d: u32 = after_w[3..4].parse().ok()?;
2556                let weekday = iso_weekday(d)?;
2557                return NaiveDate::from_isoywd_opt(year, week, weekday);
2558            }
2559            // YYYY-Www (exactly 2 chars after W: "ww")
2560            if after_w.len() == 2 && after_w.chars().all(|c| c.is_ascii_digit()) {
2561                let week: u32 = after_w.parse().ok()?;
2562                return NaiveDate::from_isoywd_opt(year, week, Weekday::Mon);
2563            }
2564        }
2565        return None;
2566    }
2567
2568    // 2. Compact ISO week dates: YYYYWww or YYYYWwwD
2569    if let Some(w_pos) = s.find('W') {
2570        if w_pos == 4 && s.len() >= 7 {
2571            let year: i32 = s[..4].parse().ok()?;
2572            let after_w = &s[w_pos + 1..];
2573            if after_w.len() == 2 || after_w.len() == 3 {
2574                let week: u32 = after_w[..2].parse().ok()?;
2575                let weekday = if after_w.len() == 3 {
2576                    let d: u32 = after_w[2..3].parse().ok()?;
2577                    iso_weekday(d)?
2578                } else {
2579                    Weekday::Mon
2580                };
2581                return NaiveDate::from_isoywd_opt(year, week, weekday);
2582            }
2583        }
2584        return None;
2585    }
2586
2587    // 3. Separator formats with dash (no W present)
2588    if s.len() >= 7 && s.as_bytes()[4] == b'-' && s[..4].chars().all(|c| c.is_ascii_digit()) {
2589        let year: i32 = s[..4].parse().ok()?;
2590        let after_dash = &s[5..];
2591
2592        // YYYY-DDD (separator ordinal date): 3 trailing digits, total 8 chars
2593        if after_dash.len() == 3 && after_dash.chars().all(|c| c.is_ascii_digit()) {
2594            let ordinal: u32 = after_dash.parse().ok()?;
2595            return NaiveDate::from_yo_opt(year, ordinal);
2596        }
2597
2598        // YYYY-MM (separator year-month): 2 trailing digits, total 7 chars
2599        if after_dash.len() == 2 && after_dash.chars().all(|c| c.is_ascii_digit()) {
2600            let month: u32 = after_dash.parse().ok()?;
2601            return NaiveDate::from_ymd_opt(year, month, 1);
2602        }
2603    }
2604
2605    // 4. All-digit compact formats
2606    if !s.chars().all(|c| c.is_ascii_digit()) {
2607        return None;
2608    }
2609
2610    match s.len() {
2611        // YYYYMMDD
2612        8 => {
2613            let year: i32 = s[..4].parse().ok()?;
2614            let month: u32 = s[4..6].parse().ok()?;
2615            let day: u32 = s[6..8].parse().ok()?;
2616            NaiveDate::from_ymd_opt(year, month, day)
2617        }
2618        // YYYYDDD (ordinal date)
2619        7 => {
2620            let year: i32 = s[..4].parse().ok()?;
2621            let ordinal: u32 = s[4..7].parse().ok()?;
2622            NaiveDate::from_yo_opt(year, ordinal)
2623        }
2624        // YYYYMM (compact year-month)
2625        6 => {
2626            let year: i32 = s[..4].parse().ok()?;
2627            let month: u32 = s[4..6].parse().ok()?;
2628            NaiveDate::from_ymd_opt(year, month, 1)
2629        }
2630        // YYYY (year only)
2631        4 => {
2632            let year: i32 = s.parse().ok()?;
2633            NaiveDate::from_ymd_opt(year, 1, 1)
2634        }
2635        _ => None,
2636    }
2637}
2638
2639/// Try parsing a compact ISO 8601 time string (no colon separators).
2640///
2641/// Supports:
2642/// - `HHMMSS`       (6 digits, e.g., `143000` -> 14:30:00)
2643/// - `HHMMSS.fff..` (6 digits + fractional, e.g., `143000.123456789` -> 14:30:00.123456789)
2644/// - `HHMM`         (4 digits, e.g., `1430` -> 14:30:00)
2645fn try_parse_compact_time(s: &str) -> Option<NaiveTime> {
2646    // Split on '.' for fractional seconds
2647    let (integer_part, frac_part) = if let Some(dot_pos) = s.find('.') {
2648        (&s[..dot_pos], Some(&s[dot_pos + 1..]))
2649    } else {
2650        (s, None)
2651    };
2652
2653    // Integer part must be all digits
2654    if !integer_part.chars().all(|c| c.is_ascii_digit()) {
2655        return None;
2656    }
2657
2658    match integer_part.len() {
2659        // HHMMSS or HHMMSS.fff
2660        6 => {
2661            let hour: u32 = integer_part[..2].parse().ok()?;
2662            let min: u32 = integer_part[2..4].parse().ok()?;
2663            let sec: u32 = integer_part[4..6].parse().ok()?;
2664            if let Some(frac) = frac_part {
2665                // Parse fractional seconds up to nanosecond precision
2666                // Pad or truncate to 9 digits for nanoseconds
2667                let mut frac_str = frac.to_string();
2668                if frac_str.len() > 9 {
2669                    frac_str.truncate(9);
2670                }
2671                while frac_str.len() < 9 {
2672                    frac_str.push('0');
2673                }
2674                let nanos: u32 = frac_str.parse().ok()?;
2675                NaiveTime::from_hms_nano_opt(hour, min, sec, nanos)
2676            } else {
2677                NaiveTime::from_hms_opt(hour, min, sec)
2678            }
2679        }
2680        // HHMM
2681        4 => {
2682            if frac_part.is_some() {
2683                return None; // HHMM.fff doesn't make sense
2684            }
2685            let hour: u32 = integer_part[..2].parse().ok()?;
2686            let min: u32 = integer_part[2..4].parse().ok()?;
2687            NaiveTime::from_hms_opt(hour, min, 0)
2688        }
2689        // HH (hour only)
2690        2 => {
2691            if frac_part.is_some() {
2692                return None; // HH.fff doesn't make sense
2693            }
2694            let hour: u32 = integer_part.parse().ok()?;
2695            NaiveTime::from_hms_opt(hour, 0, 0)
2696        }
2697        _ => None,
2698    }
2699}
2700
2701/// Try parsing a string as a NaiveTime using common formats (%H:%M:%S%.f, %H:%M:%S, %H:%M),
2702/// with fallback to compact ISO 8601 formats (HHMMSS, HHMMSS.fff, HHMM).
2703fn try_parse_naive_time(s: &str) -> Result<NaiveTime, chrono::ParseError> {
2704    NaiveTime::parse_from_str(s, "%H:%M:%S%.f")
2705        .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S"))
2706        .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M"))
2707        .or_else(|e| try_parse_compact_time(s).ok_or(e))
2708}
2709
2710/// Try parsing a string as a NaiveDateTime using common ISO formats,
2711/// with fallback to compact ISO 8601 formats (e.g., `19840711T143000`).
2712fn try_parse_naive_datetime(s: &str) -> Result<NaiveDateTime, chrono::ParseError> {
2713    NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
2714        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f"))
2715        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"))
2716        .or_else(|e| {
2717            // Compact datetime: split on 'T' and parse date/time with compact rules
2718            if let Some(t_pos) = s.find('T') {
2719                let date_part = &s[..t_pos];
2720                let time_part = &s[t_pos + 1..];
2721                let date = try_parse_compact_date(date_part);
2722                let time = try_parse_compact_time(time_part)
2723                    .or_else(|| try_parse_naive_time(time_part).ok());
2724                if let (Some(d), Some(t)) = (date, time) {
2725                    return Ok(d.and_time(t));
2726                }
2727            }
2728            // Compact date only (no T): parse as date at midnight
2729            if let Some(date) = try_parse_compact_date(s) {
2730                let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
2731                return Ok(date.and_time(midnight));
2732            }
2733            Err(e)
2734        })
2735}
2736
2737/// Parse a datetime string and extract date, time, and timezone info.
2738pub fn parse_datetime_with_tz(s: &str) -> Result<(NaiveDate, NaiveTime, Option<TimezoneInfo>)> {
2739    let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
2740    let today = Utc::now().date_naive();
2741
2742    // Check for named timezone suffix like [Europe/Stockholm]
2743    let (datetime_part, tz_name) = if let Some(bracket_pos) = s.find('[') {
2744        let tz_name = s[bracket_pos + 1..s.len() - 1].to_string();
2745        (&s[..bracket_pos], Some(tz_name))
2746    } else {
2747        (s, None)
2748    };
2749
2750    // Try parsing as full datetime with timezone
2751    if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_part) {
2752        let tz_info = if let Some(name) = tz_name {
2753            Some(parse_timezone(&name)?)
2754        } else {
2755            Some(TimezoneInfo::FixedOffset(dt.offset().fix()))
2756        };
2757        return Ok((dt.date_naive(), dt.time(), tz_info));
2758    }
2759
2760    // Try various datetime formats
2761    if let Ok(ndt) = try_parse_naive_datetime(datetime_part) {
2762        let tz_info = tz_name.map(|n| parse_timezone(&n)).transpose()?;
2763        return Ok((ndt.date(), ndt.time(), tz_info));
2764    }
2765
2766    // Date only
2767    if let Ok(d) = NaiveDate::parse_from_str(datetime_part, "%Y-%m-%d") {
2768        let tz_info = tz_name.map(|n| parse_timezone(&n)).transpose()?;
2769        return Ok((d, midnight, tz_info));
2770    }
2771
2772    // Compact date formats (YYYYMMDD, YYYYDDD, YYYYWww, YYYYWwwD)
2773    if let Some(d) = try_parse_compact_date(datetime_part) {
2774        let tz_info = tz_name.map(|n| parse_timezone(&n)).transpose()?;
2775        return Ok((d, midnight, tz_info));
2776    }
2777
2778    // Try parsing as datetime or time with non-RFC3339 timezone offset
2779    // (e.g., "2015-07-21T21:40:32.142+0100" or "12:31:14.645876123+01:00")
2780    //
2781    // By this point, all date-only formats (YYYY-MM-DD, YYYY-MM, YYYY-DDD, etc.)
2782    // have already been tried and rejected. Only time-with-offset or
2783    // datetime-with-offset strings reach here.
2784    if let Some(tz_pos) = datetime_part.rfind('+').or_else(|| {
2785        // Find the last '-' that's part of timezone, not date.
2786        // The minimum before a timezone offset is HH (2 chars for time-only)
2787        // or T + HH (3 chars after T for datetime).
2788        datetime_part.rfind('-').filter(|&pos| {
2789            if let Some(t_pos) = datetime_part.find('T') {
2790                // Datetime: '-' must be at least T + HH after T
2791                pos >= t_pos + 3
2792            } else {
2793                // Time-only: '-' must be after at least HH
2794                pos >= 2
2795            }
2796        })
2797    }) {
2798        let left_part = &datetime_part[..tz_pos];
2799        let tz_part = &datetime_part[tz_pos..];
2800
2801        let resolve_tz = |tz_name: Option<String>, tz_part: &str| -> Result<Option<TimezoneInfo>> {
2802            if let Some(name) = tz_name {
2803                Ok(Some(parse_timezone(&name)?))
2804            } else {
2805                let offset = parse_timezone_offset(tz_part)?;
2806                let fo = FixedOffset::east_opt(offset)
2807                    .ok_or_else(|| anyhow!("Invalid timezone offset"))?;
2808                Ok(Some(TimezoneInfo::FixedOffset(fo)))
2809            }
2810        };
2811
2812        // Try parsing the left part as time first (for short strings like "2140", "21",
2813        // "21:40", etc. that could be ambiguous with compact dates like YYYY).
2814        // Only try time-first when there's no 'T' separator (pure time+offset).
2815        if !left_part.contains('T')
2816            && let Ok(time) = try_parse_naive_time(left_part)
2817            && let Ok(tz_info) = resolve_tz(tz_name.clone(), tz_part)
2818        {
2819            return Ok((today, time, tz_info));
2820        }
2821
2822        // Try parsing the left part as a full datetime
2823        if let Ok(ndt) = try_parse_naive_datetime(left_part) {
2824            let tz_info = resolve_tz(tz_name, tz_part)?;
2825            return Ok((ndt.date(), ndt.time(), tz_info));
2826        }
2827
2828        // Try parsing the left part as time only (when datetime attempt failed)
2829        if left_part.contains('T')
2830            && let Ok(time) = try_parse_naive_time(left_part)
2831        {
2832            let tz_info = resolve_tz(tz_name, tz_part)?;
2833            return Ok((today, time, tz_info));
2834        }
2835    }
2836
2837    // Try parsing datetime or time with Z suffix
2838    if let Some(base) = datetime_part
2839        .strip_suffix('Z')
2840        .or_else(|| datetime_part.strip_suffix('z'))
2841    {
2842        let utc_tz = Some(TimezoneInfo::FixedOffset(FixedOffset::east_opt(0).unwrap()));
2843        // Try as datetime first (e.g., "2015-W30-2T214032.142Z")
2844        if let Ok(ndt) = try_parse_naive_datetime(base) {
2845            let tz_info = tz_name.map(|n| parse_timezone(&n)).transpose()?.or(utc_tz);
2846            return Ok((ndt.date(), ndt.time(), tz_info));
2847        }
2848        // Try as time only
2849        if let Ok(time) = try_parse_naive_time(base) {
2850            let tz_info = tz_name.map(|n| parse_timezone(&n)).transpose()?.or(utc_tz);
2851            return Ok((today, time, tz_info));
2852        }
2853    }
2854
2855    // Try parsing as plain time (no timezone offset, e.g., "14:30" or "12:31:14.645876123")
2856    if let Ok(time) = try_parse_naive_time(datetime_part) {
2857        let tz_info = tz_name.map(|n| parse_timezone(&n)).transpose()?;
2858        return Ok((today, time, tz_info));
2859    }
2860
2861    Err(anyhow!("Cannot parse datetime: {}", s))
2862}
2863
2864/// Select the chrono format string for the time portion based on nanosecond precision.
2865fn nanos_precision_format(nanos: u32, seconds: u32) -> &'static str {
2866    if nanos == 0 && seconds == 0 {
2867        "%Y-%m-%dT%H:%M"
2868    } else if nanos == 0 {
2869        "%Y-%m-%dT%H:%M:%S"
2870    } else if nanos.is_multiple_of(1_000_000) {
2871        "%Y-%m-%dT%H:%M:%S%.3f"
2872    } else if nanos.is_multiple_of(1_000) {
2873        "%Y-%m-%dT%H:%M:%S%.6f"
2874    } else {
2875        "%Y-%m-%dT%H:%M:%S%.9f"
2876    }
2877}
2878
2879fn format_datetime_with_nanos(dt: &DateTime<Utc>) -> String {
2880    let fmt = nanos_precision_format(dt.nanosecond(), dt.second());
2881    format!("{}Z", dt.format(fmt))
2882}
2883
2884fn format_datetime_with_offset_and_tz(dt: &DateTime<FixedOffset>, tz_name: Option<&str>) -> String {
2885    let fmt = nanos_precision_format(dt.nanosecond(), dt.second());
2886    let tz_suffix = format_timezone_offset(dt.offset().local_minus_utc());
2887    let base = format!("{}{}", dt.format(fmt), tz_suffix);
2888
2889    if let Some(name) = tz_name {
2890        format!("{}[{}]", base, name)
2891    } else {
2892        base
2893    }
2894}
2895
2896fn format_naive_datetime(ndt: &NaiveDateTime) -> String {
2897    let fmt = nanos_precision_format(ndt.nanosecond(), ndt.second());
2898    ndt.format(fmt).to_string()
2899}
2900
2901// ============================================================================
2902// CypherDuration for ISO 8601 formatting
2903// ============================================================================
2904
2905/// Represents a Cypher duration with separate month, day, and nanosecond components.
2906///
2907/// This allows proper ISO 8601 formatting without loss of calendar semantics.
2908#[derive(Debug, Clone, PartialEq)]
2909pub struct CypherDuration {
2910    /// Months (includes years * 12)
2911    pub months: i64,
2912    /// Days (includes weeks * 7)
2913    pub days: i64,
2914    /// Nanoseconds (time portion only, excludes days)
2915    pub nanos: i64,
2916}
2917
2918impl CypherDuration {
2919    pub fn new(months: i64, days: i64, nanos: i64) -> Self {
2920        Self {
2921            months,
2922            days,
2923            nanos,
2924        }
2925    }
2926
2927    /// Convert this duration to a `Value::Temporal(TemporalValue::Duration)`.
2928    pub fn to_temporal_value(&self) -> Value {
2929        Value::Temporal(TemporalValue::Duration {
2930            months: self.months,
2931            days: self.days,
2932            nanos: self.nanos,
2933        })
2934    }
2935
2936    /// Create from total microseconds (loses calendar semantics).
2937    pub fn from_micros(micros: i64) -> Self {
2938        let total_nanos = micros * 1000;
2939        let total_secs = total_nanos / NANOS_PER_SECOND;
2940        let remaining_nanos = total_nanos % NANOS_PER_SECOND;
2941
2942        let days = total_secs / (24 * 3600);
2943        let day_secs = total_secs % (24 * 3600);
2944
2945        Self {
2946            months: 0,
2947            days,
2948            nanos: day_secs * NANOS_PER_SECOND + remaining_nanos,
2949        }
2950    }
2951
2952    /// Format as ISO 8601 duration string.
2953    ///
2954    /// Handles negative components and mixed-sign seconds/nanoseconds correctly.
2955    pub fn to_iso8601(&self) -> String {
2956        let mut result = String::from("P");
2957
2958        let years = self.months / 12;
2959        let months = self.months % 12;
2960
2961        if years != 0 {
2962            result.push_str(&format!("{}Y", years));
2963        }
2964        if months != 0 {
2965            result.push_str(&format!("{}M", months));
2966        }
2967        if self.days != 0 {
2968            result.push_str(&format!("{}D", self.days));
2969        }
2970
2971        // Time part: use truncating division (towards zero) so each component
2972        // independently carries its sign, matching Neo4j's format.
2973        let nanos = self.nanos;
2974        let total_secs = nanos / NANOS_PER_SECOND; // truncates towards zero
2975        let remaining_nanos = nanos % NANOS_PER_SECOND; // same sign as nanos
2976
2977        let hours = total_secs / 3600;
2978        let rem_after_hours = total_secs % 3600;
2979        let minutes = rem_after_hours / 60;
2980        let seconds = rem_after_hours % 60;
2981
2982        if hours != 0 || minutes != 0 || seconds != 0 || remaining_nanos != 0 {
2983            result.push('T');
2984
2985            if hours != 0 {
2986                result.push_str(&format!("{}H", hours));
2987            }
2988            if minutes != 0 {
2989                result.push_str(&format!("{}M", minutes));
2990            }
2991            if seconds != 0 || remaining_nanos != 0 {
2992                if remaining_nanos != 0 {
2993                    // Combine seconds + remaining nanos into fractional seconds.
2994                    // Both have the same sign (truncating division preserves sign).
2995                    let secs_with_nanos = seconds as f64 + (remaining_nanos as f64 / 1e9);
2996                    let formatted = format!("{:.9}", secs_with_nanos);
2997                    let trimmed = formatted.trim_end_matches('0').trim_end_matches('.');
2998                    result.push_str(trimmed);
2999                    result.push('S');
3000                } else {
3001                    result.push_str(&format!("{}S", seconds));
3002                }
3003            }
3004        }
3005
3006        // Handle case where duration is zero
3007        if result == "P" {
3008            result.push_str("T0S");
3009        }
3010
3011        result
3012    }
3013
3014    /// Get total as microseconds (for arithmetic operations).
3015    pub fn to_micros(&self) -> i64 {
3016        let month_days = self.months * 30; // Approximate
3017        let total_days = month_days + self.days;
3018        let day_micros = total_days * MICROS_PER_DAY;
3019        let nano_micros = self.nanos / 1000;
3020        day_micros + nano_micros
3021    }
3022
3023    /// Component-wise addition of two durations.
3024    pub fn add(&self, other: &CypherDuration) -> CypherDuration {
3025        CypherDuration::new(
3026            self.months + other.months,
3027            self.days + other.days,
3028            self.nanos + other.nanos,
3029        )
3030    }
3031
3032    /// Component-wise subtraction of two durations.
3033    pub fn sub(&self, other: &CypherDuration) -> CypherDuration {
3034        CypherDuration::new(
3035            self.months - other.months,
3036            self.days - other.days,
3037            self.nanos - other.nanos,
3038        )
3039    }
3040
3041    /// Negate all components.
3042    pub fn negate(&self) -> CypherDuration {
3043        CypherDuration::new(-self.months, -self.days, -self.nanos)
3044    }
3045
3046    /// Multiply duration by a factor with fractional cascading.
3047    pub fn multiply(&self, factor: f64) -> CypherDuration {
3048        let months_f = self.months as f64 * factor;
3049        let whole_months = months_f.trunc() as i64;
3050        let frac_months = months_f.fract();
3051
3052        // Cascade fractional months via average Gregorian month (2629746 seconds).
3053        let frac_month_seconds = frac_months * 2_629_746.0;
3054        let extra_days_from_months = (frac_month_seconds / SECONDS_PER_DAY as f64).trunc();
3055        let remaining_secs_from_months =
3056            frac_month_seconds - extra_days_from_months * SECONDS_PER_DAY as f64;
3057
3058        let days_f = self.days as f64 * factor + extra_days_from_months;
3059        let whole_days = days_f.trunc() as i64;
3060        let frac_days = days_f.fract();
3061
3062        let nanos_f = self.nanos as f64 * factor
3063            + remaining_secs_from_months * NANOS_PER_SECOND as f64
3064            + frac_days * NANOS_PER_DAY as f64;
3065
3066        CypherDuration::new(whole_months, whole_days, nanos_f.trunc() as i64)
3067    }
3068
3069    /// Divide duration by a divisor with fractional cascading.
3070    pub fn divide(&self, divisor: f64) -> CypherDuration {
3071        if divisor == 0.0 {
3072            // Return zero duration for division by zero (matches Cypher behavior)
3073            return CypherDuration::new(0, 0, 0);
3074        }
3075        self.multiply(1.0 / divisor)
3076    }
3077}
3078
3079// ============================================================================
3080// Calendar-Aware Duration Arithmetic
3081// ============================================================================
3082
3083/// Add months to a date with day-of-month clamping.
3084///
3085/// If the resulting month has fewer days than the source day,
3086/// the day is clamped to the last day of the month.
3087/// For example, `Jan 31 + 1 month = Feb 28` (or Feb 29 in leap years).
3088pub fn add_months_to_date(date: NaiveDate, months: i64) -> NaiveDate {
3089    if months == 0 {
3090        return date;
3091    }
3092
3093    let total_months = date.year() as i64 * 12 + (date.month() as i64 - 1) + months;
3094    let new_year = total_months.div_euclid(12) as i32;
3095    let new_month = (total_months.rem_euclid(12) + 1) as u32;
3096
3097    // Clamp day to valid range for the new month
3098    let max_day = days_in_month(new_year, new_month);
3099    let new_day = date.day().min(max_day);
3100
3101    NaiveDate::from_ymd_opt(new_year, new_month, new_day)
3102        .unwrap_or_else(|| NaiveDate::from_ymd_opt(new_year, new_month, 1).unwrap())
3103}
3104
3105/// Get number of days in a given month.
3106fn days_in_month(year: i32, month: u32) -> u32 {
3107    match month {
3108        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
3109        4 | 6 | 9 | 11 => 30,
3110        2 => {
3111            if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
3112                29
3113            } else {
3114                28
3115            }
3116        }
3117        _ => 30,
3118    }
3119}
3120
3121/// Add a CypherDuration to a date string, returning the result date string.
3122///
3123/// Algorithm: add months (clamping) -> add days -> add nanos (overflow into extra days).
3124pub fn add_cypher_duration_to_date(date_str: &str, dur: &CypherDuration) -> Result<String> {
3125    let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?;
3126
3127    // Step 1: Add months with clamping
3128    let after_months = add_months_to_date(date, dur.months);
3129
3130    // Step 2: Add days (checked: out-of-range yields an error, not a panic)
3131    let after_days = after_months
3132        .checked_add_signed(Duration::days(dur.days))
3133        .ok_or_else(|| anyhow!("date arithmetic out of range"))?;
3134
3135    // Step 3: Add nanos (overflow into extra full days; sub-day remainder is discarded for dates)
3136    // Use truncating division (/) so negative sub-day nanos don't subtract an extra day.
3137    let extra_days = dur.nanos / NANOS_PER_DAY;
3138    let result = after_days
3139        .checked_add_signed(Duration::days(extra_days))
3140        .ok_or_else(|| anyhow!("date arithmetic out of range"))?;
3141
3142    Ok(result.format("%Y-%m-%d").to_string())
3143}
3144
3145/// Add a CypherDuration to a local time string, returning the result time string.
3146///
3147/// Time wraps modulo 24 hours. No date component is affected.
3148pub fn add_cypher_duration_to_localtime(time_str: &str, dur: &CypherDuration) -> Result<String> {
3149    let time = parse_time_string(time_str)?;
3150    let total_nanos = time_to_nanos(&time)
3151        .checked_add(dur.nanos)
3152        .ok_or_else(|| anyhow!("time arithmetic out of range"))?;
3153    // Wrap modulo 24h
3154    let wrapped = total_nanos.rem_euclid(NANOS_PER_DAY);
3155    let result = nanos_to_time(wrapped);
3156    Ok(format_time_with_nanos(&result))
3157}
3158
3159/// Add a CypherDuration to a time-with-timezone string, returning the result time string.
3160///
3161/// Time wraps modulo 24 hours, preserving the original timezone offset.
3162pub fn add_cypher_duration_to_time(time_str: &str, dur: &CypherDuration) -> Result<String> {
3163    let (_, time, tz_info) = parse_datetime_with_tz(time_str)?;
3164    let total_nanos = time_to_nanos(&time)
3165        .checked_add(dur.nanos)
3166        .ok_or_else(|| anyhow!("time arithmetic out of range"))?;
3167    let wrapped = total_nanos.rem_euclid(NANOS_PER_DAY);
3168    let result_time = nanos_to_time(wrapped);
3169
3170    let time_part = format_time_with_nanos(&result_time);
3171    if let Some(ref tz) = tz_info {
3172        let today = NaiveDate::from_ymd_opt(2000, 1, 1).unwrap();
3173        let ndt = NaiveDateTime::new(today, result_time);
3174        let offset = tz.offset_for_local(&ndt)?;
3175        let offset_str = format_timezone_offset(offset.local_minus_utc());
3176        Ok(format!("{}{}", time_part, offset_str))
3177    } else {
3178        Ok(time_part)
3179    }
3180}
3181
3182/// Add a CypherDuration to a local datetime string, returning the result string.
3183pub fn add_cypher_duration_to_localdatetime(dt_str: &str, dur: &CypherDuration) -> Result<String> {
3184    let ndt = NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S")
3185        .or_else(|_| NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%.f"))
3186        .or_else(|_| NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M"))
3187        .map_err(|_| anyhow!("Invalid localdatetime: {}", dt_str))?;
3188
3189    // Step 1: Add months with clamping
3190    let after_months = add_months_to_date(ndt.date(), dur.months);
3191    // Step 2: Add days (checked)
3192    let after_days = after_months
3193        .checked_add_signed(Duration::days(dur.days))
3194        .ok_or_else(|| anyhow!("localdatetime arithmetic out of range"))?;
3195    // Step 3: Add nanos to time (checked)
3196    let result_ndt = NaiveDateTime::new(after_days, ndt.time())
3197        .checked_add_signed(Duration::nanoseconds(dur.nanos))
3198        .ok_or_else(|| anyhow!("localdatetime arithmetic out of range"))?;
3199
3200    Ok(format_naive_datetime(&result_ndt))
3201}
3202
3203/// Add a CypherDuration to a datetime-with-timezone string, returning the result string.
3204pub fn add_cypher_duration_to_datetime(dt_str: &str, dur: &CypherDuration) -> Result<String> {
3205    let (date, time, tz_info) = parse_datetime_with_tz(dt_str)?;
3206
3207    // Step 1: Add months with clamping
3208    let after_months = add_months_to_date(date, dur.months);
3209    // Step 2: Add days (checked)
3210    let after_days = after_months
3211        .checked_add_signed(Duration::days(dur.days))
3212        .ok_or_else(|| anyhow!("datetime arithmetic out of range"))?;
3213    // Step 3: Add nanos to the datetime (checked)
3214    let ndt = NaiveDateTime::new(after_days, time)
3215        .checked_add_signed(Duration::nanoseconds(dur.nanos))
3216        .ok_or_else(|| anyhow!("datetime arithmetic out of range"))?;
3217
3218    if let Some(ref tz) = tz_info {
3219        let offset = tz.offset_for_local(&ndt)?;
3220        let dt = offset
3221            .from_local_datetime(&ndt)
3222            .single()
3223            .ok_or_else(|| anyhow!("Ambiguous local time after duration addition"))?;
3224        Ok(format_datetime_with_offset_and_tz(&dt, tz.name()))
3225    } else {
3226        let dt = DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc);
3227        Ok(format_datetime_with_nanos(&dt))
3228    }
3229}
3230
3231/// Convert NaiveTime to total nanoseconds since midnight.
3232fn time_to_nanos(t: &NaiveTime) -> i64 {
3233    t.hour() as i64 * 3_600 * NANOS_PER_SECOND
3234        + t.minute() as i64 * 60 * NANOS_PER_SECOND
3235        + t.second() as i64 * NANOS_PER_SECOND
3236        + t.nanosecond() as i64
3237}
3238
3239/// Convert total nanoseconds since midnight to NaiveTime.
3240fn nanos_to_time(nanos: i64) -> NaiveTime {
3241    let total_secs = nanos / NANOS_PER_SECOND;
3242    let remaining_nanos = (nanos % NANOS_PER_SECOND) as u32;
3243    let h = (total_secs / 3600) as u32;
3244    let m = ((total_secs % 3600) / 60) as u32;
3245    let s = (total_secs % 60) as u32;
3246    NaiveTime::from_hms_nano_opt(h, m, s, remaining_nanos)
3247        .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap())
3248}
3249
3250// ============================================================================
3251// Duration Constructor
3252// ============================================================================
3253
3254fn eval_duration(args: &[Value]) -> Result<Value> {
3255    if args.len() != 1 {
3256        return Err(anyhow!("duration() requires 1 argument"));
3257    }
3258
3259    match &args[0] {
3260        Value::String(s) => {
3261            let duration = parse_duration_to_cypher(s)?;
3262            Ok(Value::Temporal(TemporalValue::Duration {
3263                months: duration.months,
3264                days: duration.days,
3265                nanos: duration.nanos,
3266            }))
3267        }
3268        Value::Temporal(TemporalValue::Duration { .. }) => Ok(args[0].clone()),
3269        Value::Map(map) => eval_duration_from_map(map),
3270        Value::Int(_) | Value::Float(_) => {
3271            if let Some(micros) = args[0].as_i64() {
3272                let duration = CypherDuration::from_micros(micros);
3273                Ok(Value::Temporal(TemporalValue::Duration {
3274                    months: duration.months,
3275                    days: duration.days,
3276                    nanos: duration.nanos,
3277                }))
3278            } else {
3279                Ok(args[0].clone())
3280            }
3281        }
3282        Value::Null => Ok(Value::Null),
3283        _ => Err(anyhow!("duration() expects a string, map, or number")),
3284    }
3285}
3286
3287/// Build duration from a map with fractional cascading.
3288///
3289/// Fractional parts cascade to the next smaller unit:
3290/// - `months: 5.5` -> 5 months + 15 days (0.5 * 30)
3291/// - `days: 14.5` -> 14 days + 12 hours (0.5 * 24h in nanos)
3292fn eval_duration_from_map(map: &HashMap<String, Value>) -> Result<Value> {
3293    let mut months_f: f64 = 0.0;
3294    let mut days_f: f64 = 0.0;
3295    let mut nanos_f: f64 = 0.0;
3296
3297    // Calendar components with fractional cascading
3298    if let Some(years) = map.get("years").and_then(get_numeric_value) {
3299        months_f += years * 12.0;
3300    }
3301    if let Some(m) = map.get("months").and_then(get_numeric_value) {
3302        months_f += m;
3303    }
3304
3305    // Cascade fractional months to days + remaining nanos.
3306    // Neo4j uses average Gregorian month: 2629746 seconds (= 365.2425 * 86400 / 12).
3307    let whole_months = months_f.trunc() as i64;
3308    let frac_months = months_f.fract();
3309    let frac_month_seconds = frac_months * 2_629_746.0;
3310    let extra_days_from_months = (frac_month_seconds / SECONDS_PER_DAY as f64).trunc();
3311    let remaining_secs_from_months =
3312        frac_month_seconds - extra_days_from_months * SECONDS_PER_DAY as f64;
3313    days_f += extra_days_from_months;
3314    nanos_f += remaining_secs_from_months * NANOS_PER_SECOND as f64;
3315
3316    if let Some(weeks) = map.get("weeks").and_then(get_numeric_value) {
3317        days_f += weeks * 7.0;
3318    }
3319    if let Some(d) = map.get("days").and_then(get_numeric_value) {
3320        days_f += d;
3321    }
3322
3323    // Cascade fractional days to nanos (1 day = 24h in nanos)
3324    let whole_days = days_f.trunc() as i64;
3325    let frac_days = days_f.fract();
3326    nanos_f += frac_days * NANOS_PER_DAY as f64;
3327
3328    // Time components (stored as nanoseconds)
3329    if let Some(hours) = map.get("hours").and_then(get_numeric_value) {
3330        nanos_f += hours * 3600.0 * NANOS_PER_SECOND as f64;
3331    }
3332    if let Some(minutes) = map.get("minutes").and_then(get_numeric_value) {
3333        nanos_f += minutes * 60.0 * NANOS_PER_SECOND as f64;
3334    }
3335    if let Some(seconds) = map.get("seconds").and_then(get_numeric_value) {
3336        nanos_f += seconds * NANOS_PER_SECOND as f64;
3337    }
3338    if let Some(millis) = map.get("milliseconds").and_then(get_numeric_value) {
3339        nanos_f += millis * 1_000_000.0;
3340    }
3341    if let Some(micros) = map.get("microseconds").and_then(get_numeric_value) {
3342        nanos_f += micros * 1_000.0;
3343    }
3344    if let Some(n) = map.get("nanoseconds").and_then(get_numeric_value) {
3345        nanos_f += n;
3346    }
3347
3348    let duration = CypherDuration::new(whole_months, whole_days, nanos_f.trunc() as i64);
3349    Ok(Value::Temporal(TemporalValue::Duration {
3350        months: duration.months,
3351        days: duration.days,
3352        nanos: duration.nanos,
3353    }))
3354}
3355
3356/// Extract numeric value from JSON, supporting both integers and floats.
3357fn get_numeric_value(v: &Value) -> Option<f64> {
3358    v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))
3359}
3360
3361/// Parse ISO 8601 duration format (e.g., "P1DT2H30M15S").
3362fn parse_iso8601_duration(s: &str) -> Result<i64> {
3363    let s = &s[1..]; // Skip 'P'
3364    let mut total_micros: i64 = 0;
3365    let mut in_time_part = false;
3366    let mut num_buf = String::new();
3367
3368    for c in s.chars() {
3369        if c == 'T' || c == 't' {
3370            in_time_part = true;
3371            continue;
3372        }
3373
3374        if c.is_ascii_digit() || c == '.' || c == '-' {
3375            num_buf.push(c);
3376        } else {
3377            if num_buf.is_empty() {
3378                continue;
3379            }
3380            let num: f64 = num_buf
3381                .parse()
3382                .map_err(|_| anyhow!("Invalid duration number"))?;
3383            num_buf.clear();
3384
3385            let micros = match c {
3386                'Y' | 'y' => (num * 365.0 * MICROS_PER_DAY as f64) as i64,
3387                'M' if !in_time_part => (num * 30.0 * MICROS_PER_DAY as f64) as i64, // Months
3388                'W' | 'w' => (num * 7.0 * MICROS_PER_DAY as f64) as i64,
3389                'D' | 'd' => (num * MICROS_PER_DAY as f64) as i64,
3390                'H' | 'h' => (num * MICROS_PER_HOUR as f64) as i64,
3391                'M' | 'm' if in_time_part => (num * MICROS_PER_MINUTE as f64) as i64, // Minutes
3392                'S' | 's' => (num * MICROS_PER_SECOND as f64) as i64,
3393                _ => return Err(anyhow!("Invalid ISO 8601 duration designator: {}", c)),
3394            };
3395            total_micros += micros;
3396        }
3397    }
3398
3399    Ok(total_micros)
3400}
3401
3402/// Parse simple duration format (e.g., "1d2h30m15s", "90s", "1h30m").
3403fn parse_simple_duration(s: &str) -> Result<i64> {
3404    let mut total_micros: i64 = 0;
3405    let mut num_buf = String::new();
3406
3407    for c in s.chars() {
3408        if c.is_ascii_digit() || c == '.' || c == '-' {
3409            num_buf.push(c);
3410        } else if c.is_ascii_alphabetic() {
3411            if num_buf.is_empty() {
3412                return Err(anyhow!("Invalid duration format"));
3413            }
3414            let num: f64 = num_buf
3415                .parse()
3416                .map_err(|_| anyhow!("Invalid duration number"))?;
3417            num_buf.clear();
3418
3419            let micros = match c {
3420                'w' => (num * 7.0 * MICROS_PER_DAY as f64) as i64,
3421                'd' => (num * MICROS_PER_DAY as f64) as i64,
3422                'h' => (num * MICROS_PER_HOUR as f64) as i64,
3423                'm' => (num * MICROS_PER_MINUTE as f64) as i64,
3424                's' => (num * MICROS_PER_SECOND as f64) as i64,
3425                _ => return Err(anyhow!("Invalid duration unit: {}", c)),
3426            };
3427            total_micros += micros;
3428        }
3429    }
3430
3431    // Handle case where string is just a number (assume seconds)
3432    if !num_buf.is_empty() {
3433        let num: f64 = num_buf
3434            .parse()
3435            .map_err(|_| anyhow!("Invalid duration number"))?;
3436        total_micros += (num * MICROS_PER_SECOND as f64) as i64;
3437    }
3438
3439    Ok(total_micros)
3440}
3441
3442// ============================================================================
3443// Epoch Functions
3444// ============================================================================
3445
3446fn eval_datetime_fromepoch(args: &[Value]) -> Result<Value> {
3447    let seconds = args
3448        .first()
3449        .and_then(|v| v.as_i64())
3450        .ok_or_else(|| anyhow!("datetime.fromepoch requires seconds argument"))?;
3451    let nanos = args.get(1).and_then(|v| v.as_i64()).unwrap_or(0) as u32;
3452
3453    let dt = DateTime::from_timestamp(seconds, nanos)
3454        .ok_or_else(|| anyhow!("Invalid epoch timestamp: {}", seconds))?;
3455    let epoch_nanos = dt.timestamp_nanos_opt().unwrap_or(0);
3456    Ok(Value::Temporal(TemporalValue::DateTime {
3457        nanos_since_epoch: epoch_nanos,
3458        offset_seconds: 0,
3459        timezone_name: None,
3460    }))
3461}
3462
3463fn eval_datetime_fromepochmillis(args: &[Value]) -> Result<Value> {
3464    let millis = args
3465        .first()
3466        .and_then(|v| v.as_i64())
3467        .ok_or_else(|| anyhow!("datetime.fromepochmillis requires milliseconds argument"))?;
3468
3469    let dt = DateTime::from_timestamp_millis(millis)
3470        .ok_or_else(|| anyhow!("Invalid epoch millis: {}", millis))?;
3471    let epoch_nanos = dt.timestamp_nanos_opt().unwrap_or(0);
3472    Ok(Value::Temporal(TemporalValue::DateTime {
3473        nanos_since_epoch: epoch_nanos,
3474        offset_seconds: 0,
3475        timezone_name: None,
3476    }))
3477}
3478
3479// ============================================================================
3480// Truncate Functions
3481// ============================================================================
3482
3483fn eval_truncate(type_name: &str, args: &[Value]) -> Result<Value> {
3484    if args.is_empty() {
3485        return Err(anyhow!(
3486            "{}.truncate requires at least a unit argument",
3487            type_name
3488        ));
3489    }
3490
3491    let unit = args
3492        .first()
3493        .and_then(|v| v.as_str())
3494        .ok_or_else(|| anyhow!("truncate requires unit as first argument"))?;
3495
3496    let temporal = args.get(1);
3497    let adjust_map = args.get(2).and_then(|v| v.as_object());
3498
3499    match type_name {
3500        "date" => truncate_date(unit, temporal, adjust_map),
3501        "time" => truncate_time(unit, temporal, adjust_map, true),
3502        "localtime" => truncate_time(unit, temporal, adjust_map, false),
3503        "datetime" | "localdatetime" => truncate_datetime(unit, temporal, adjust_map, type_name),
3504        _ => Err(anyhow!("Unknown truncate type: {}", type_name)),
3505    }
3506}
3507
3508fn truncate_date(
3509    unit: &str,
3510    temporal: Option<&Value>,
3511    adjust_map: Option<&HashMap<String, Value>>,
3512) -> Result<Value> {
3513    let date = match temporal {
3514        Some(Value::Temporal(_)) => temporal_or_string_to_date(temporal.unwrap())?,
3515        Some(Value::String(s)) => parse_date_string(s)?,
3516        Some(Value::Null) | None => Utc::now().date_naive(),
3517        _ => return Err(anyhow!("truncate expects a date string")),
3518    };
3519
3520    let truncated = truncate_date_to_unit(date, unit)?;
3521
3522    if let Some(map) = adjust_map {
3523        apply_date_adjustments(truncated, map)
3524    } else {
3525        Ok(Value::Temporal(TemporalValue::Date {
3526            days_since_epoch: date_to_days_since_epoch(&truncated),
3527        }))
3528    }
3529}
3530
3531fn truncate_date_to_unit(date: NaiveDate, unit: &str) -> Result<NaiveDate> {
3532    let unit_lower = unit.to_lowercase();
3533    match unit_lower.as_str() {
3534        "millennium" => {
3535            // 2017 -> 2000, 1984 -> 1000, 999 -> 0
3536            let millennium_year = (date.year() / 1000) * 1000;
3537            NaiveDate::from_ymd_opt(millennium_year, 1, 1)
3538                .ok_or_else(|| anyhow!("Invalid millennium truncation"))
3539        }
3540        "century" => {
3541            // 1984 -> 1900, 2017 -> 2000
3542            let century_year = (date.year() / 100) * 100;
3543            NaiveDate::from_ymd_opt(century_year, 1, 1)
3544                .ok_or_else(|| anyhow!("Invalid century truncation"))
3545        }
3546        "decade" => {
3547            let decade_year = (date.year() / 10) * 10;
3548            NaiveDate::from_ymd_opt(decade_year, 1, 1)
3549                .ok_or_else(|| anyhow!("Invalid decade truncation"))
3550        }
3551        "year" => NaiveDate::from_ymd_opt(date.year(), 1, 1)
3552            .ok_or_else(|| anyhow!("Invalid year truncation")),
3553        "weekyear" => {
3554            // Truncate to first day of ISO week year
3555            let iso_week = date.iso_week();
3556            let week_year = iso_week.year();
3557            let jan4 =
3558                NaiveDate::from_ymd_opt(week_year, 1, 4).ok_or_else(|| anyhow!("Invalid date"))?;
3559            let iso_week_day = jan4.weekday().num_days_from_monday();
3560            Ok(jan4 - Duration::days(iso_week_day as i64))
3561        }
3562        "quarter" => {
3563            let quarter = (date.month() - 1) / 3;
3564            let first_month = quarter * 3 + 1;
3565            NaiveDate::from_ymd_opt(date.year(), first_month, 1)
3566                .ok_or_else(|| anyhow!("Invalid quarter truncation"))
3567        }
3568        "month" => NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
3569            .ok_or_else(|| anyhow!("Invalid month truncation")),
3570        "week" => {
3571            // Truncate to Monday of current week
3572            let weekday = date.weekday().num_days_from_monday();
3573            Ok(date - Duration::days(weekday as i64))
3574        }
3575        "day" => Ok(date),
3576        _ => Err(anyhow!("Unknown truncation unit for date: {}", unit)),
3577    }
3578}
3579
3580fn apply_date_adjustments(date: NaiveDate, map: &HashMap<String, Value>) -> Result<Value> {
3581    let mut result = date;
3582
3583    // Handle dayOfWeek adjustment (moves to different day in the same week)
3584    if let Some(dow) = map.get("dayOfWeek").and_then(|v| v.as_i64()) {
3585        // dayOfWeek: 1=Monday, 7=Sunday
3586        // Calculate the offset from Monday
3587        let current_dow = result.weekday().num_days_from_monday() as i64 + 1;
3588        let diff = dow - current_dow;
3589        result += Duration::days(diff);
3590    }
3591
3592    if let Some(month) = map.get("month").and_then(|v| v.as_i64()) {
3593        result = NaiveDate::from_ymd_opt(result.year(), month as u32, result.day())
3594            .ok_or_else(|| anyhow!("Invalid month adjustment"))?;
3595    }
3596    if let Some(day) = map.get("day").and_then(|v| v.as_i64()) {
3597        result = NaiveDate::from_ymd_opt(result.year(), result.month(), day as u32)
3598            .ok_or_else(|| anyhow!("Invalid day adjustment"))?;
3599    }
3600
3601    Ok(Value::Temporal(TemporalValue::Date {
3602        days_since_epoch: date_to_days_since_epoch(&result),
3603    }))
3604}
3605
3606fn truncate_time(
3607    unit: &str,
3608    temporal: Option<&Value>,
3609    adjust_map: Option<&HashMap<String, Value>>,
3610    with_timezone: bool,
3611) -> Result<Value> {
3612    let (date, time, tz_info) = match temporal {
3613        Some(Value::Temporal(tv)) => {
3614            let t = tv
3615                .to_time()
3616                .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
3617            let offset = match tv {
3618                TemporalValue::Time { offset_seconds, .. }
3619                | TemporalValue::DateTime { offset_seconds, .. } => Some(
3620                    TimezoneInfo::FixedOffset(FixedOffset::east_opt(*offset_seconds).unwrap()),
3621                ),
3622                _ => None,
3623            };
3624            (Utc::now().date_naive(), t, offset)
3625        }
3626        Some(Value::String(s)) => {
3627            // Try to parse as datetime/time with timezone first
3628            if let Ok((date, time, tz)) = parse_datetime_with_tz(s) {
3629                (date, time, tz)
3630            } else if let Ok(t) = parse_time_string(s) {
3631                // Use today for time-only parsing
3632                (Utc::now().date_naive(), t, None)
3633            } else {
3634                return Err(anyhow!("truncate expects a time string"));
3635            }
3636        }
3637        Some(Value::Null) | None => {
3638            let now = Utc::now();
3639            (now.date_naive(), now.time(), None)
3640        }
3641        _ => return Err(anyhow!("truncate expects a time string")),
3642    };
3643
3644    // Check if adjustment map specifies a timezone override
3645    let effective_tz = if let Some(map) = adjust_map {
3646        if let Some(tz_str) = map.get("timezone").and_then(|v| v.as_str()) {
3647            Some(parse_timezone(tz_str)?)
3648        } else {
3649            tz_info
3650        }
3651    } else {
3652        tz_info
3653    };
3654
3655    let truncated = truncate_time_to_unit(time, unit)?;
3656
3657    let final_time = if let Some(map) = adjust_map {
3658        apply_time_adjustments(truncated, map)?
3659    } else {
3660        truncated
3661    };
3662
3663    // Return typed temporal value
3664    let nanos = time_to_nanos(&final_time);
3665    if with_timezone {
3666        let offset = if let Some(ref tz) = effective_tz {
3667            tz.offset_seconds_with_date(&date)
3668        } else {
3669            0
3670        };
3671        Ok(Value::Temporal(TemporalValue::Time {
3672            nanos_since_midnight: nanos,
3673            offset_seconds: offset,
3674        }))
3675    } else {
3676        Ok(Value::Temporal(TemporalValue::LocalTime {
3677            nanos_since_midnight: nanos,
3678        }))
3679    }
3680}
3681
3682fn truncate_time_to_unit(time: NaiveTime, unit: &str) -> Result<NaiveTime> {
3683    let unit_lower = unit.to_lowercase();
3684    match unit_lower.as_str() {
3685        "day" => NaiveTime::from_hms_opt(0, 0, 0).ok_or_else(|| anyhow!("Invalid truncation")),
3686        "hour" => {
3687            NaiveTime::from_hms_opt(time.hour(), 0, 0).ok_or_else(|| anyhow!("Invalid truncation"))
3688        }
3689        "minute" => NaiveTime::from_hms_opt(time.hour(), time.minute(), 0)
3690            .ok_or_else(|| anyhow!("Invalid truncation")),
3691        "second" => NaiveTime::from_hms_opt(time.hour(), time.minute(), time.second())
3692            .ok_or_else(|| anyhow!("Invalid truncation")),
3693        "millisecond" => {
3694            let millis = time.nanosecond() / 1_000_000;
3695            NaiveTime::from_hms_nano_opt(
3696                time.hour(),
3697                time.minute(),
3698                time.second(),
3699                millis * 1_000_000,
3700            )
3701            .ok_or_else(|| anyhow!("Invalid truncation"))
3702        }
3703        "microsecond" => {
3704            let micros = time.nanosecond() / 1_000;
3705            NaiveTime::from_hms_nano_opt(time.hour(), time.minute(), time.second(), micros * 1_000)
3706                .ok_or_else(|| anyhow!("Invalid truncation"))
3707        }
3708        _ => Err(anyhow!("Unknown truncation unit for time: {}", unit)),
3709    }
3710}
3711
3712/// Apply time adjustments from a map and return the adjusted NaiveTime.
3713fn apply_time_adjustments(time: NaiveTime, map: &HashMap<String, Value>) -> Result<NaiveTime> {
3714    let hour = map
3715        .get("hour")
3716        .and_then(|v| v.as_i64())
3717        .unwrap_or(time.hour() as i64) as u32;
3718    let minute = map
3719        .get("minute")
3720        .and_then(|v| v.as_i64())
3721        .unwrap_or(time.minute() as i64) as u32;
3722    let second = map
3723        .get("second")
3724        .and_then(|v| v.as_i64())
3725        .unwrap_or(time.second() as i64) as u32;
3726    let nanos = build_nanoseconds_with_base(map, time.nanosecond());
3727
3728    NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)
3729        .ok_or_else(|| anyhow!("Invalid time adjustment"))
3730}
3731
3732fn truncate_datetime(
3733    unit: &str,
3734    temporal: Option<&Value>,
3735    adjust_map: Option<&HashMap<String, Value>>,
3736    type_name: &str,
3737) -> Result<Value> {
3738    let (date, time, tz_info) = match temporal {
3739        Some(Value::Temporal(_)) => temporal_or_string_to_components(temporal.unwrap())?,
3740        Some(Value::String(s)) => {
3741            // Use the new parser that preserves timezone info
3742            parse_datetime_with_tz(s)?
3743        }
3744        Some(Value::Null) | None => {
3745            let now = Utc::now();
3746            (
3747                now.date_naive(),
3748                now.time(),
3749                Some(TimezoneInfo::FixedOffset(FixedOffset::east_opt(0).unwrap())),
3750            )
3751        }
3752        _ => return Err(anyhow!("truncate expects a datetime string")),
3753    };
3754
3755    // Check if adjustment map specifies a timezone
3756    let effective_tz = if let Some(map) = adjust_map {
3757        if let Some(tz_str) = map.get("timezone").and_then(|v| v.as_str()) {
3758            Some(parse_timezone(tz_str)?)
3759        } else {
3760            tz_info
3761        }
3762    } else {
3763        tz_info
3764    };
3765
3766    // Truncate based on unit
3767    let (truncated_date, truncated_time) = truncate_datetime_to_unit(date, time, unit)?;
3768
3769    if let Some(map) = adjust_map {
3770        apply_datetime_adjustments(
3771            truncated_date,
3772            truncated_time,
3773            map,
3774            type_name,
3775            effective_tz.as_ref(),
3776        )
3777    } else {
3778        let ndt = NaiveDateTime::new(truncated_date, truncated_time);
3779        if type_name == "localdatetime" {
3780            Ok(localdatetime_value_from_naive(&ndt))
3781        } else if let Some(ref tz) = effective_tz {
3782            let offset = tz.offset_for_local(&ndt)?;
3783            let offset_secs = offset.local_minus_utc();
3784            Ok(datetime_value_from_local_and_offset(
3785                &ndt,
3786                offset_secs,
3787                tz.name().map(|s| s.to_string()),
3788            ))
3789        } else {
3790            Ok(datetime_value_from_local_and_offset(&ndt, 0, None))
3791        }
3792    }
3793}
3794
3795fn truncate_datetime_to_unit(
3796    date: NaiveDate,
3797    time: NaiveTime,
3798    unit: &str,
3799) -> Result<(NaiveDate, NaiveTime)> {
3800    let unit_lower = unit.to_lowercase();
3801    let midnight =
3802        NaiveTime::from_hms_opt(0, 0, 0).ok_or_else(|| anyhow!("Failed to create midnight"))?;
3803
3804    match unit_lower.as_str() {
3805        // Date-level truncations reset time to midnight
3806        "millennium" | "century" | "decade" | "year" | "weekyear" | "quarter" | "month"
3807        | "week" | "day" => {
3808            let truncated_date = truncate_date_to_unit(date, unit)?;
3809            Ok((truncated_date, midnight))
3810        }
3811        // Time-level truncations keep the date
3812        "hour" | "minute" | "second" | "millisecond" | "microsecond" => {
3813            let truncated_time = truncate_time_to_unit(time, unit)?;
3814            Ok((date, truncated_time))
3815        }
3816        _ => Err(anyhow!("Unknown truncation unit: {}", unit)),
3817    }
3818}
3819
3820fn apply_datetime_adjustments(
3821    date: NaiveDate,
3822    time: NaiveTime,
3823    map: &HashMap<String, Value>,
3824    type_name: &str,
3825    tz_info: Option<&TimezoneInfo>,
3826) -> Result<Value> {
3827    // Apply date adjustments
3828    let year = map
3829        .get("year")
3830        .and_then(|v| v.as_i64())
3831        .unwrap_or(date.year() as i64) as i32;
3832    let month = map
3833        .get("month")
3834        .and_then(|v| v.as_i64())
3835        .unwrap_or(date.month() as i64) as u32;
3836    let day = map
3837        .get("day")
3838        .and_then(|v| v.as_i64())
3839        .unwrap_or(date.day() as i64) as u32;
3840
3841    // Apply time adjustments
3842    let hour = map
3843        .get("hour")
3844        .and_then(|v| v.as_i64())
3845        .unwrap_or(time.hour() as i64) as u32;
3846    let minute = map
3847        .get("minute")
3848        .and_then(|v| v.as_i64())
3849        .unwrap_or(time.minute() as i64) as u32;
3850    let second = map
3851        .get("second")
3852        .and_then(|v| v.as_i64())
3853        .unwrap_or(time.second() as i64) as u32;
3854    let nanos = build_nanoseconds_with_base(map, time.nanosecond());
3855
3856    let mut adjusted_date = NaiveDate::from_ymd_opt(year, month, day)
3857        .ok_or_else(|| anyhow!("Invalid date in adjustment"))?;
3858
3859    // Handle dayOfWeek adjustment (moves to different day in the same week)
3860    if let Some(dow) = map.get("dayOfWeek").and_then(|v| v.as_i64()) {
3861        let current_dow = adjusted_date.weekday().num_days_from_monday() as i64 + 1;
3862        let diff = dow - current_dow;
3863        adjusted_date += Duration::days(diff);
3864    }
3865
3866    let adjusted_time = NaiveTime::from_hms_nano_opt(hour, minute, second, nanos)
3867        .ok_or_else(|| anyhow!("Invalid time in adjustment"))?;
3868
3869    let ndt = NaiveDateTime::new(adjusted_date, adjusted_time);
3870
3871    if type_name == "localdatetime" {
3872        Ok(localdatetime_value_from_naive(&ndt))
3873    } else if let Some(tz) = tz_info {
3874        let offset = tz.offset_for_local(&ndt)?;
3875        let offset_secs = offset.local_minus_utc();
3876        Ok(datetime_value_from_local_and_offset(
3877            &ndt,
3878            offset_secs,
3879            tz.name().map(|s| s.to_string()),
3880        ))
3881    } else {
3882        Ok(datetime_value_from_local_and_offset(&ndt, 0, None))
3883    }
3884}
3885
3886// ============================================================================
3887// Duration Between Functions
3888// ============================================================================
3889
3890#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3891struct ExtendedDate {
3892    year: i64,
3893    month: u32,
3894    day: u32,
3895}
3896
3897#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3898struct ExtendedLocalDateTime {
3899    date: ExtendedDate,
3900    hour: u32,
3901    minute: u32,
3902    second: u32,
3903    nanosecond: u32,
3904}
3905
3906fn is_leap_year_i64(year: i64) -> bool {
3907    year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0)
3908}
3909
3910fn days_in_month_i64(year: i64, month: u32) -> Option<u32> {
3911    let days = match month {
3912        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
3913        4 | 6 | 9 | 11 => 30,
3914        2 => {
3915            if is_leap_year_i64(year) {
3916                29
3917            } else {
3918                28
3919            }
3920        }
3921        _ => return None,
3922    };
3923    Some(days)
3924}
3925
3926fn parse_extended_date_string(s: &str) -> Option<ExtendedDate> {
3927    let bytes = s.as_bytes();
3928    if bytes.is_empty() {
3929        return None;
3930    }
3931
3932    let mut idx = 0usize;
3933    if matches!(bytes[0], b'+' | b'-') {
3934        idx += 1;
3935    }
3936    if idx >= bytes.len() || !bytes[idx].is_ascii_digit() {
3937        return None;
3938    }
3939
3940    while idx < bytes.len() && bytes[idx].is_ascii_digit() {
3941        idx += 1;
3942    }
3943    if idx >= bytes.len() || bytes[idx] != b'-' {
3944        return None;
3945    }
3946
3947    let year: i64 = s[..idx].parse().ok()?;
3948    let rest = &s[idx + 1..];
3949    let (month_str, day_str) = rest.split_once('-')?;
3950    if month_str.len() != 2 || day_str.len() != 2 {
3951        return None;
3952    }
3953    let month: u32 = month_str.parse().ok()?;
3954    let day: u32 = day_str.parse().ok()?;
3955    let max_day = days_in_month_i64(year, month)?;
3956    if day == 0 || day > max_day {
3957        return None;
3958    }
3959    Some(ExtendedDate { year, month, day })
3960}
3961
3962fn parse_extended_localdatetime_string(s: &str) -> Option<ExtendedLocalDateTime> {
3963    let (date_part, time_part) = if let Some((d, t)) = s.split_once('T') {
3964        (d, Some(t))
3965    } else {
3966        (s, None)
3967    };
3968
3969    let date = parse_extended_date_string(date_part)?;
3970
3971    let Some(time_part) = time_part else {
3972        return Some(ExtendedLocalDateTime {
3973            date,
3974            hour: 0,
3975            minute: 0,
3976            second: 0,
3977            nanosecond: 0,
3978        });
3979    };
3980
3981    if time_part.contains('+') || time_part.contains('Z') || time_part.contains('z') {
3982        return None;
3983    }
3984    let (hms_part, frac_part) = if let Some((hms, frac)) = time_part.split_once('.') {
3985        (hms, Some(frac))
3986    } else {
3987        (time_part, None)
3988    };
3989    let mut parts = hms_part.split(':');
3990    let hour: u32 = parts.next()?.parse().ok()?;
3991    let minute: u32 = parts.next()?.parse().ok()?;
3992    let second: u32 = parts.next().map(|v| v.parse().ok()).unwrap_or(Some(0))?;
3993    if parts.next().is_some() {
3994        return None;
3995    }
3996    if hour > 23 || minute > 59 || second > 59 {
3997        return None;
3998    }
3999
4000    let nanosecond = if let Some(frac) = frac_part {
4001        if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
4002            return None;
4003        }
4004        let mut frac_buf = frac.to_string();
4005        if frac_buf.len() > 9 {
4006            frac_buf.truncate(9);
4007        }
4008        while frac_buf.len() < 9 {
4009            frac_buf.push('0');
4010        }
4011        frac_buf.parse().ok()?
4012    } else {
4013        0
4014    };
4015
4016    Some(ExtendedLocalDateTime {
4017        date,
4018        hour,
4019        minute,
4020        second,
4021        nanosecond,
4022    })
4023}
4024
4025fn days_from_civil(date: ExtendedDate) -> i128 {
4026    // Howard Hinnant's civil-from-days algorithm, adapted for wide i64 year range.
4027    let mut y = date.year;
4028    let m = date.month as i64;
4029    let d = date.day as i64;
4030    y -= if m <= 2 { 1 } else { 0 };
4031    let era = y.div_euclid(400);
4032    let yoe = y - era * 400;
4033    let mp = m + if m > 2 { -3 } else { 9 };
4034    let doy = (153 * mp + 2) / 5 + d - 1;
4035    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
4036    era as i128 * 146_097 + doe as i128 - 719_468
4037}
4038
4039fn calendar_months_between_extended(start: &ExtendedDate, end: &ExtendedDate) -> i64 {
4040    let year_diff = end.year - start.year;
4041    let month_diff = end.month as i64 - start.month as i64;
4042    let total_months = year_diff * 12 + month_diff;
4043
4044    if total_months > 0 && end.day < start.day {
4045        total_months - 1
4046    } else if total_months < 0 && end.day > start.day {
4047        total_months + 1
4048    } else {
4049        total_months
4050    }
4051}
4052
4053fn add_months_to_extended_date(date: ExtendedDate, months: i64) -> ExtendedDate {
4054    if months == 0 {
4055        return date;
4056    }
4057
4058    let total_months = date.year as i128 * 12 + (date.month as i128 - 1) + months as i128;
4059    let year = total_months.div_euclid(12) as i64;
4060    let month = (total_months.rem_euclid(12) + 1) as u32;
4061    let max_day = days_in_month_i64(year, month).unwrap_or(31);
4062    let day = date.day.min(max_day);
4063
4064    ExtendedDate { year, month, day }
4065}
4066
4067fn remaining_days_after_months_extended(
4068    start: &ExtendedDate,
4069    end: &ExtendedDate,
4070    months: i64,
4071) -> i64 {
4072    let after_months = add_months_to_extended_date(*start, months);
4073    (days_from_civil(*end) - days_from_civil(after_months)) as i64
4074}
4075
4076fn try_extended_date_from_value(val: &Value) -> Option<ExtendedDate> {
4077    match val {
4078        Value::String(s) => parse_extended_date_string(s),
4079        _ => None,
4080    }
4081}
4082
4083fn try_extended_localdatetime_from_value(val: &Value) -> Option<ExtendedLocalDateTime> {
4084    match val {
4085        Value::String(s) => parse_extended_localdatetime_string(s),
4086        _ => None,
4087    }
4088}
4089
4090fn try_eval_duration_between_extended(args: &[Value]) -> Result<Option<Value>> {
4091    let Some(start) = try_extended_date_from_value(&args[0]) else {
4092        return Ok(None);
4093    };
4094    let Some(end) = try_extended_date_from_value(&args[1]) else {
4095        return Ok(None);
4096    };
4097
4098    let months = calendar_months_between_extended(&start, &end);
4099    let remaining_days = remaining_days_after_months_extended(&start, &end, months);
4100    let dur = CypherDuration::new(months, remaining_days, 0);
4101    Ok(Some(Value::String(dur.to_iso8601())))
4102}
4103
4104fn format_time_only_duration_nanos(total_nanos: i128) -> String {
4105    if total_nanos == 0 {
4106        return "PT0S".to_string();
4107    }
4108    let total_secs = total_nanos / NANOS_PER_SECOND as i128;
4109    let rem_nanos = total_nanos % NANOS_PER_SECOND as i128;
4110
4111    let hours = total_secs / 3600;
4112    let rem_after_hours = total_secs % 3600;
4113    let minutes = rem_after_hours / 60;
4114    let seconds = rem_after_hours % 60;
4115
4116    let mut out = String::from("PT");
4117    if hours != 0 {
4118        out.push_str(&format!("{hours}H"));
4119    }
4120    if minutes != 0 {
4121        out.push_str(&format!("{minutes}M"));
4122    }
4123    if seconds != 0 || rem_nanos != 0 {
4124        if rem_nanos == 0 {
4125            out.push_str(&format!("{seconds}S"));
4126        } else {
4127            let sign = if total_nanos < 0 && seconds == 0 {
4128                "-"
4129            } else {
4130                ""
4131            };
4132            let secs_abs = seconds.abs();
4133            let nanos_abs = rem_nanos.abs();
4134            let frac = format!("{nanos_abs:09}");
4135            let trimmed = frac.trim_end_matches('0');
4136            out.push_str(&format!("{sign}{secs_abs}.{trimmed}S"));
4137        }
4138    }
4139    if out == "PT" { "PT0S".to_string() } else { out }
4140}
4141
4142fn try_eval_duration_in_seconds_extended(args: &[Value]) -> Result<Option<Value>> {
4143    let Some(start) = try_extended_localdatetime_from_value(&args[0]) else {
4144        return Ok(None);
4145    };
4146    let Some(end) = try_extended_localdatetime_from_value(&args[1]) else {
4147        return Ok(None);
4148    };
4149
4150    let start_days = days_from_civil(start.date);
4151    let end_days = days_from_civil(end.date);
4152    let start_tod_nanos =
4153        (start.hour as i128 * 3600 + start.minute as i128 * 60 + start.second as i128)
4154            * NANOS_PER_SECOND as i128
4155            + start.nanosecond as i128;
4156    let end_tod_nanos = (end.hour as i128 * 3600 + end.minute as i128 * 60 + end.second as i128)
4157        * NANOS_PER_SECOND as i128
4158        + end.nanosecond as i128;
4159    let total_nanos =
4160        (end_days - start_days) * NANOS_PER_DAY as i128 + (end_tod_nanos - start_tod_nanos);
4161
4162    if total_nanos >= i64::MIN as i128 && total_nanos <= i64::MAX as i128 {
4163        let dur = CypherDuration::new(0, 0, total_nanos as i64);
4164        Ok(Some(dur.to_temporal_value()))
4165    } else {
4166        Ok(Some(Value::String(format_time_only_duration_nanos(
4167            total_nanos,
4168        ))))
4169    }
4170}
4171
4172/// Compute calendar months between two dates.
4173///
4174/// Returns the number of whole months from `start` to `end`.
4175/// Negative if `end` is before `start`.
4176fn calendar_months_between(start: &NaiveDate, end: &NaiveDate) -> i64 {
4177    let year_diff = end.year() as i64 - start.year() as i64;
4178    let month_diff = end.month() as i64 - start.month() as i64;
4179    let total_months = year_diff * 12 + month_diff;
4180
4181    // Adjust if end day is before start day (incomplete month)
4182    if total_months > 0 && end.day() < start.day() {
4183        total_months - 1
4184    } else if total_months < 0 && end.day() > start.day() {
4185        total_months + 1
4186    } else {
4187        total_months
4188    }
4189}
4190
4191/// Compute the remaining days after removing whole months.
4192fn remaining_days_after_months(start: &NaiveDate, end: &NaiveDate, months: i64) -> i64 {
4193    let after_months = add_months_to_date(*start, months);
4194    end.signed_duration_since(after_months).num_days()
4195}
4196
4197fn eval_duration_between(args: &[Value]) -> Result<Value> {
4198    if args.len() < 2 {
4199        return Err(anyhow!("duration.between requires two temporal arguments"));
4200    }
4201    if args[0].is_null() || args[1].is_null() {
4202        return Ok(Value::Null);
4203    }
4204
4205    let start_res = parse_temporal_value_typed(&args[0]);
4206    let end_res = parse_temporal_value_typed(&args[1]);
4207    let (start, end) = match (start_res, end_res) {
4208        (Ok(start), Ok(end)) => (start, end),
4209        (start_res, end_res) => {
4210            if let Some(value) = try_eval_duration_between_extended(args)? {
4211                return Ok(value);
4212            }
4213            return Err(start_res
4214                .err()
4215                .or_else(|| end_res.err())
4216                .unwrap_or_else(|| anyhow!("duration.between requires two temporal arguments")));
4217        }
4218    };
4219
4220    let start_has_date = has_date_component(start.ttype);
4221    let end_has_date = has_date_component(end.ttype);
4222    let start_has_time = has_time_component(start.ttype);
4223    let end_has_time = has_time_component(end.ttype);
4224
4225    // Both are date-only: return calendar months + remaining days, no time component.
4226    if start.ttype == TemporalType::Date && end.ttype == TemporalType::Date {
4227        let months = calendar_months_between(&start.local_date, &end.local_date);
4228        let remaining_days =
4229            remaining_days_after_months(&start.local_date, &end.local_date, months);
4230        let dur = CypherDuration::new(months, remaining_days, 0);
4231        return Ok(dur.to_temporal_value());
4232    }
4233
4234    // Both have date and time: calendar months + remaining time as nanos (no days).
4235    // Only use UTC normalization when BOTH operands have timezone info.
4236    if start_has_date && end_has_date && start_has_time && end_has_time {
4237        let tz_aware = both_tz_aware(&start, &end);
4238        let (s_date, s_time, e_date, e_time) = if tz_aware {
4239            (
4240                start.utc_datetime.date(),
4241                start.utc_datetime.time(),
4242                end.utc_datetime.date(),
4243                end.utc_datetime.time(),
4244            )
4245        } else {
4246            (
4247                start.local_date,
4248                start.local_time,
4249                end.local_date,
4250                end.local_time,
4251            )
4252        };
4253
4254        let months = calendar_months_between(&s_date, &e_date);
4255        let date_after_months = add_months_to_date(s_date, months);
4256        let start_dt = NaiveDateTime::new(date_after_months, s_time);
4257        let end_dt = NaiveDateTime::new(e_date, e_time);
4258        let remaining_nanos = end_dt
4259            .signed_duration_since(start_dt)
4260            .num_nanoseconds()
4261            .unwrap_or(0);
4262
4263        let dur = CypherDuration::new(months, 0, remaining_nanos);
4264        return Ok(dur.to_temporal_value());
4265    }
4266
4267    // One has date+time, other is date-only: months + days + remaining time.
4268    if start_has_date && end_has_date {
4269        let tz_aware = both_tz_aware(&start, &end);
4270        let (s_date, s_time, e_date, e_time) = if tz_aware {
4271            (
4272                start.utc_datetime.date(),
4273                start.utc_datetime.time(),
4274                end.utc_datetime.date(),
4275                end.utc_datetime.time(),
4276            )
4277        } else {
4278            (
4279                start.local_date,
4280                start.local_time,
4281                end.local_date,
4282                end.local_time,
4283            )
4284        };
4285
4286        let months = calendar_months_between(&s_date, &e_date);
4287        let date_after_months = add_months_to_date(s_date, months);
4288        let start_dt = NaiveDateTime::new(date_after_months, s_time);
4289        let end_dt = NaiveDateTime::new(e_date, e_time);
4290        let remaining = end_dt.signed_duration_since(start_dt);
4291        let remaining_days = remaining.num_days();
4292        let remaining_nanos =
4293            remaining.num_nanoseconds().unwrap_or(0) - remaining_days * 86_400_000_000_000;
4294
4295        let dur = CypherDuration::new(months, remaining_days, remaining_nanos);
4296        return Ok(dur.to_temporal_value());
4297    }
4298
4299    // Cross-type: one has date, other is time-only, or both time-only.
4300    // Use UTC normalization only when BOTH operands have timezone info.
4301    let tz_aware = both_tz_aware(&start, &end);
4302    let start_time = if tz_aware {
4303        start.utc_datetime.time()
4304    } else {
4305        start.local_time
4306    };
4307    let end_time = if tz_aware {
4308        end.utc_datetime.time()
4309    } else {
4310        end.local_time
4311    };
4312
4313    let start_nanos = time_to_nanos(&start_time);
4314    let end_nanos = time_to_nanos(&end_time);
4315    let nanos_diff = end_nanos - start_nanos;
4316
4317    let dur = CypherDuration::new(0, 0, nanos_diff);
4318    Ok(dur.to_temporal_value())
4319}
4320
4321/// Check if a temporal type has a date component.
4322fn has_date_component(ttype: TemporalType) -> bool {
4323    matches!(
4324        ttype,
4325        TemporalType::Date | TemporalType::LocalDateTime | TemporalType::DateTime
4326    )
4327}
4328
4329/// Check if a temporal type has a time component.
4330fn has_time_component(ttype: TemporalType) -> bool {
4331    matches!(
4332        ttype,
4333        TemporalType::LocalTime
4334            | TemporalType::Time
4335            | TemporalType::LocalDateTime
4336            | TemporalType::DateTime
4337    )
4338}
4339
4340fn eval_duration_in_months(args: &[Value]) -> Result<Value> {
4341    if args.len() < 2 {
4342        return Err(anyhow!("duration.inMonths requires two temporal arguments"));
4343    }
4344    if args[0].is_null() || args[1].is_null() {
4345        return Ok(Value::Null);
4346    }
4347
4348    let start = parse_temporal_value_typed(&args[0])?;
4349    let end = parse_temporal_value_typed(&args[1])?;
4350
4351    if has_date_component(start.ttype) && has_date_component(end.ttype) {
4352        // Only use UTC normalization when both operands have timezone info
4353        let tz_aware = both_tz_aware(&start, &end);
4354        let (s_date, s_time, e_date, e_time) = if tz_aware {
4355            (
4356                start.utc_datetime.date(),
4357                start.utc_datetime.time(),
4358                end.utc_datetime.date(),
4359                end.utc_datetime.time(),
4360            )
4361        } else {
4362            (
4363                start.local_date,
4364                start.local_time,
4365                end.local_date,
4366                end.local_time,
4367            )
4368        };
4369        let mut months = calendar_months_between(&s_date, &e_date);
4370        // Adjust months if the time component crosses the day boundary:
4371        // When both fall on the same day-of-month, time determines if we've
4372        // crossed the boundary. E.g., 2018-07-21T00:00 -> 2016-07-21T21:40
4373        // is only 23 months (not 24) because end time is later in the day.
4374        if s_date.day() == e_date.day() {
4375            if months > 0 && e_time < s_time {
4376                months -= 1;
4377            } else if months < 0 && e_time > s_time {
4378                months += 1;
4379            }
4380        }
4381        let dur = CypherDuration::new(months, 0, 0);
4382        Ok(dur.to_temporal_value())
4383    } else {
4384        Ok(Value::Temporal(TemporalValue::Duration {
4385            months: 0,
4386            days: 0,
4387            nanos: 0,
4388        }))
4389    }
4390}
4391
4392fn eval_duration_in_days(args: &[Value]) -> Result<Value> {
4393    if args.len() < 2 {
4394        return Err(anyhow!("duration.inDays requires two temporal arguments"));
4395    }
4396    if args[0].is_null() || args[1].is_null() {
4397        return Ok(Value::Null);
4398    }
4399
4400    let start = parse_temporal_value_typed(&args[0])?;
4401    let end = parse_temporal_value_typed(&args[1])?;
4402
4403    if has_date_component(start.ttype) && has_date_component(end.ttype) {
4404        // Only use UTC normalization when both operands have timezone info.
4405        let tz_aware = both_tz_aware(&start, &end);
4406        let (s_dt, e_dt) = if tz_aware {
4407            (start.utc_datetime, end.utc_datetime)
4408        } else {
4409            (
4410                NaiveDateTime::new(start.local_date, start.local_time),
4411                NaiveDateTime::new(end.local_date, end.local_time),
4412            )
4413        };
4414        // Compute total duration, then express as whole days (truncating toward zero).
4415        let total_nanos = e_dt
4416            .signed_duration_since(s_dt)
4417            .num_nanoseconds()
4418            .ok_or_else(|| anyhow!("Duration overflow in inDays"))?;
4419        let days = total_nanos / 86_400_000_000_000;
4420        let dur = CypherDuration::new(0, days, 0);
4421        Ok(dur.to_temporal_value())
4422    } else {
4423        Ok(Value::Temporal(TemporalValue::Duration {
4424            months: 0,
4425            days: 0,
4426            nanos: 0,
4427        }))
4428    }
4429}
4430
4431/// Normalize a local datetime to UTC using a named IANA timezone.
4432///
4433/// When one operand has a named timezone (DST-aware) and the other is local
4434/// (no timezone), the local value must be interpreted in that named timezone
4435/// to correctly account for DST transitions.
4436fn normalize_local_to_utc(ndt: NaiveDateTime, tz: Tz) -> Result<NaiveDateTime> {
4437    use chrono::TimeZone;
4438    match tz.from_local_datetime(&ndt) {
4439        chrono::LocalResult::Single(dt) => Ok(dt.naive_utc()),
4440        chrono::LocalResult::Ambiguous(earliest, _) => Ok(earliest.naive_utc()),
4441        chrono::LocalResult::None => {
4442            // In a DST gap, shift forward by 1 hour and retry.
4443            let shifted = ndt + chrono::Duration::hours(1);
4444            match tz.from_local_datetime(&shifted) {
4445                chrono::LocalResult::Single(dt) => Ok(dt.naive_utc()),
4446                chrono::LocalResult::Ambiguous(earliest, _) => Ok(earliest.naive_utc()),
4447                _ => Err(anyhow!("Cannot resolve local time in timezone")),
4448            }
4449        }
4450    }
4451}
4452
4453fn eval_duration_in_seconds(args: &[Value]) -> Result<Value> {
4454    if args.len() < 2 {
4455        return Err(anyhow!(
4456            "duration.inSeconds requires two temporal arguments"
4457        ));
4458    }
4459    if args[0].is_null() || args[1].is_null() {
4460        return Ok(Value::Null);
4461    }
4462
4463    let start_res = parse_temporal_value_typed(&args[0]);
4464    let end_res = parse_temporal_value_typed(&args[1]);
4465    let (start, end) = match (start_res, end_res) {
4466        (Ok(start), Ok(end)) => (start, end),
4467        (start_res, end_res) => {
4468            if let Some(value) = try_eval_duration_in_seconds_extended(args)? {
4469                return Ok(value);
4470            }
4471            return Err(start_res
4472                .err()
4473                .or_else(|| end_res.err())
4474                .unwrap_or_else(|| anyhow!("duration.inSeconds requires two temporal arguments")));
4475        }
4476    };
4477
4478    let start_has_date = has_date_component(start.ttype);
4479    let end_has_date = has_date_component(end.ttype);
4480
4481    // Determine the shared named timezone for DST-aware normalization.
4482    // When one operand has a named (DST-aware) timezone (e.g., Europe/Stockholm),
4483    // local operands are interpreted in that timezone for correct DST handling.
4484    let shared_named_tz = start.named_tz.or(end.named_tz);
4485
4486    // Resolve a temporal operand to a NaiveDateTime for comparison.
4487    //
4488    // Strategy:
4489    // - If a shared named timezone exists (DST scenario), normalize everything
4490    //   to UTC: tz-aware operands use their pre-computed UTC, local operands
4491    //   are interpreted in the shared named timezone then converted to UTC.
4492    // - If both operands have timezone info (fixed offsets), normalize to UTC.
4493    // - Otherwise (mixed local + fixed-offset, or both local), use face values.
4494    let have_tz = both_tz_aware(&start, &end);
4495
4496    let resolve =
4497        |pt: &ParsedTemporal, date_override: Option<NaiveDate>| -> Result<NaiveDateTime> {
4498            let local_date = date_override.unwrap_or(pt.local_date);
4499            let local_ndt = NaiveDateTime::new(local_date, pt.local_time);
4500
4501            if let Some(tz) = shared_named_tz {
4502                // DST-aware mode: normalize everything to UTC.
4503                if pt.named_tz.is_some() && date_override.is_none() {
4504                    // This operand owns the named tz — already UTC-normalized.
4505                    Ok(pt.utc_datetime)
4506                } else {
4507                    // Local operand or date-overridden: interpret in the shared tz.
4508                    normalize_local_to_utc(local_ndt, tz)
4509                }
4510            } else if have_tz {
4511                // Both have fixed offsets: use UTC normalization.
4512                if date_override.is_some() {
4513                    let offset = pt.utc_offset_secs.unwrap_or(0);
4514                    Ok(local_ndt - chrono::Duration::seconds(offset as i64))
4515                } else {
4516                    Ok(pt.utc_datetime)
4517                }
4518            } else {
4519                // Mixed local + fixed-offset or both local: use face values.
4520                Ok(local_ndt)
4521            }
4522        };
4523
4524    // Cross-type with time-only operand.
4525    if !start_has_date || !end_has_date {
4526        if shared_named_tz.is_some() {
4527            // DST mode: place time-only operand on the date-bearing operand's
4528            // local date within the shared timezone.
4529            let ref_date = if start_has_date {
4530                start.local_date
4531            } else if end_has_date {
4532                end.local_date
4533            } else {
4534                NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()
4535            };
4536            let s_dt = resolve(&start, Some(ref_date))?;
4537            let e_dt = resolve(&end, Some(ref_date))?;
4538            let total_nanos = e_dt
4539                .signed_duration_since(s_dt)
4540                .num_nanoseconds()
4541                .ok_or_else(|| anyhow!("Duration overflow in inSeconds"))?;
4542            let dur = CypherDuration::new(0, 0, total_nanos);
4543            return Ok(dur.to_temporal_value());
4544        }
4545
4546        // No named timezone: simple time difference.
4547        let s_time = if have_tz {
4548            start.utc_datetime.time()
4549        } else {
4550            start.local_time
4551        };
4552        let e_time = if have_tz {
4553            end.utc_datetime.time()
4554        } else {
4555            end.local_time
4556        };
4557        let s_nanos = time_to_nanos(&s_time);
4558        let e_nanos = time_to_nanos(&e_time);
4559        let dur = CypherDuration::new(0, 0, e_nanos - s_nanos);
4560        return Ok(dur.to_temporal_value());
4561    }
4562
4563    // Both have date: use full datetime difference.
4564    let s_dt = resolve(&start, None)?;
4565    let e_dt = resolve(&end, None)?;
4566    let total_nanos = e_dt
4567        .signed_duration_since(s_dt)
4568        .num_nanoseconds()
4569        .ok_or_else(|| anyhow!("Duration overflow in inSeconds"))?;
4570
4571    let dur = CypherDuration::new(0, 0, total_nanos);
4572    Ok(dur.to_temporal_value())
4573}
4574
4575/// Parsed temporal value with local and UTC-normalized components.
4576struct ParsedTemporal {
4577    /// Local date component (as written, before any UTC normalization).
4578    local_date: NaiveDate,
4579    /// Local time component (as written, before any UTC normalization).
4580    local_time: NaiveTime,
4581    /// UTC-normalized datetime (for absolute difference computation).
4582    utc_datetime: NaiveDateTime,
4583    /// Detected temporal type.
4584    ttype: TemporalType,
4585    /// Timezone offset in seconds from UTC, if applicable.
4586    utc_offset_secs: Option<i32>,
4587    /// The named IANA timezone, if present (for DST-aware cross-type computation).
4588    named_tz: Option<Tz>,
4589}
4590
4591/// Check whether both temporal operands carry timezone information.
4592fn both_tz_aware(a: &ParsedTemporal, b: &ParsedTemporal) -> bool {
4593    a.utc_offset_secs.is_some() && b.utc_offset_secs.is_some()
4594}
4595
4596/// Parse a temporal value into local components, UTC-normalized datetime, and type.
4597fn parse_temporal_value_typed(val: &Value) -> Result<ParsedTemporal> {
4598    let midnight =
4599        NaiveTime::from_hms_opt(0, 0, 0).ok_or_else(|| anyhow!("Failed to create midnight"))?;
4600    let epoch_date = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
4601
4602    match val {
4603        Value::String(s) => {
4604            let ttype = classify_temporal(s)
4605                .ok_or_else(|| anyhow!("Cannot classify temporal value: {}", s))?;
4606
4607            match ttype {
4608                TemporalType::DateTime => {
4609                    let (date, time, tz_info) = parse_datetime_with_tz(s)?;
4610                    let local_ndt = NaiveDateTime::new(date, time);
4611                    let iana_tz = tz_info.as_ref().and_then(|info| match info {
4612                        TimezoneInfo::Named(tz) => Some(*tz),
4613                        _ => None,
4614                    });
4615                    let offset_secs = if let Some(ref info) = tz_info {
4616                        info.offset_for_local(&local_ndt)?.local_minus_utc()
4617                    } else {
4618                        0
4619                    };
4620                    let utc_ndt = local_ndt - chrono::Duration::seconds(offset_secs as i64);
4621                    Ok(ParsedTemporal {
4622                        local_date: date,
4623                        local_time: time,
4624                        utc_datetime: utc_ndt,
4625                        ttype,
4626                        utc_offset_secs: Some(offset_secs),
4627
4628                        named_tz: iana_tz,
4629                    })
4630                }
4631                TemporalType::LocalDateTime => {
4632                    let ndt = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
4633                        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f"))
4634                        .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M"))
4635                        .map_err(|_| anyhow!("Cannot parse localdatetime: {}", s))?;
4636                    Ok(ParsedTemporal {
4637                        local_date: ndt.date(),
4638                        local_time: ndt.time(),
4639                        utc_datetime: ndt,
4640                        ttype,
4641                        utc_offset_secs: None,
4642
4643                        named_tz: None,
4644                    })
4645                }
4646                TemporalType::Date => {
4647                    let d = NaiveDate::parse_from_str(s, "%Y-%m-%d")
4648                        .map_err(|_| anyhow!("Cannot parse date: {}", s))?;
4649                    let ndt = NaiveDateTime::new(d, midnight);
4650                    Ok(ParsedTemporal {
4651                        local_date: d,
4652                        local_time: midnight,
4653                        utc_datetime: ndt,
4654                        ttype,
4655                        utc_offset_secs: None,
4656
4657                        named_tz: None,
4658                    })
4659                }
4660                TemporalType::Time => {
4661                    let (_, time, tz_info) = parse_datetime_with_tz(s)?;
4662                    let offset_secs = if let Some(ref info) = tz_info {
4663                        let dummy_ndt = NaiveDateTime::new(epoch_date, time);
4664                        info.offset_for_local(&dummy_ndt)?.local_minus_utc()
4665                    } else {
4666                        0
4667                    };
4668                    let local_ndt = NaiveDateTime::new(epoch_date, time);
4669                    let utc_ndt = local_ndt - chrono::Duration::seconds(offset_secs as i64);
4670                    Ok(ParsedTemporal {
4671                        local_date: epoch_date,
4672                        local_time: time,
4673                        utc_datetime: utc_ndt,
4674                        ttype,
4675                        utc_offset_secs: Some(offset_secs),
4676
4677                        named_tz: None,
4678                    })
4679                }
4680                TemporalType::LocalTime => {
4681                    let time = parse_time_string(s)?;
4682                    let ndt = NaiveDateTime::new(epoch_date, time);
4683                    Ok(ParsedTemporal {
4684                        local_date: epoch_date,
4685                        local_time: time,
4686                        utc_datetime: ndt,
4687                        ttype,
4688                        utc_offset_secs: None,
4689
4690                        named_tz: None,
4691                    })
4692                }
4693                TemporalType::Duration | TemporalType::Btic => {
4694                    Err(anyhow!("Cannot use {:?} as temporal argument", ttype))
4695                }
4696            }
4697        }
4698        Value::Temporal(tv) => {
4699            let ttype = tv.temporal_type();
4700            match tv {
4701                TemporalValue::Date { days_since_epoch } => {
4702                    let d = epoch_date + chrono::Duration::days(*days_since_epoch as i64);
4703                    let ndt = NaiveDateTime::new(d, midnight);
4704                    Ok(ParsedTemporal {
4705                        local_date: d,
4706                        local_time: midnight,
4707                        utc_datetime: ndt,
4708                        ttype,
4709                        utc_offset_secs: None,
4710                        named_tz: None,
4711                    })
4712                }
4713                TemporalValue::LocalTime {
4714                    nanos_since_midnight,
4715                } => {
4716                    let time = nanos_to_time(*nanos_since_midnight);
4717                    let ndt = NaiveDateTime::new(epoch_date, time);
4718                    Ok(ParsedTemporal {
4719                        local_date: epoch_date,
4720                        local_time: time,
4721                        utc_datetime: ndt,
4722                        ttype,
4723                        utc_offset_secs: None,
4724                        named_tz: None,
4725                    })
4726                }
4727                TemporalValue::Time {
4728                    nanos_since_midnight,
4729                    offset_seconds,
4730                } => {
4731                    let time = nanos_to_time(*nanos_since_midnight);
4732                    let local_ndt = NaiveDateTime::new(epoch_date, time);
4733                    let utc_ndt = local_ndt - chrono::Duration::seconds(*offset_seconds as i64);
4734                    Ok(ParsedTemporal {
4735                        local_date: epoch_date,
4736                        local_time: time,
4737                        utc_datetime: utc_ndt,
4738                        ttype,
4739                        utc_offset_secs: Some(*offset_seconds),
4740                        named_tz: None,
4741                    })
4742                }
4743                TemporalValue::LocalDateTime { nanos_since_epoch } => {
4744                    let ndt =
4745                        chrono::DateTime::from_timestamp_nanos(*nanos_since_epoch).naive_utc();
4746                    Ok(ParsedTemporal {
4747                        local_date: ndt.date(),
4748                        local_time: ndt.time(),
4749                        utc_datetime: ndt,
4750                        ttype,
4751                        utc_offset_secs: None,
4752                        named_tz: None,
4753                    })
4754                }
4755                TemporalValue::DateTime {
4756                    nanos_since_epoch,
4757                    offset_seconds,
4758                    timezone_name,
4759                } => {
4760                    // Compute local time from UTC + offset
4761                    let local_nanos = nanos_since_epoch + (*offset_seconds as i64) * 1_000_000_000;
4762                    let local_ndt = chrono::DateTime::from_timestamp_nanos(local_nanos).naive_utc();
4763                    let utc_ndt =
4764                        chrono::DateTime::from_timestamp_nanos(*nanos_since_epoch).naive_utc();
4765                    let iana_tz = timezone_name
4766                        .as_deref()
4767                        .and_then(|name| name.parse::<chrono_tz::Tz>().ok());
4768                    Ok(ParsedTemporal {
4769                        local_date: local_ndt.date(),
4770                        local_time: local_ndt.time(),
4771                        utc_datetime: utc_ndt,
4772                        ttype,
4773                        utc_offset_secs: Some(*offset_seconds),
4774                        named_tz: iana_tz,
4775                    })
4776                }
4777                TemporalValue::Duration { .. } | TemporalValue::Btic { .. } => Err(anyhow!(
4778                    "Cannot use {:?} as temporal argument",
4779                    tv.temporal_type()
4780                )),
4781            }
4782        }
4783        _ => Err(anyhow!("Expected temporal value, got: {:?}", val)),
4784    }
4785}
4786
4787// ============================================================================
4788// Tests
4789// ============================================================================
4790
4791#[cfg(test)]
4792mod tests {
4793    use super::*;
4794
4795    /// Helper to build a Value::Map from key-value pairs.
4796    fn map_val(pairs: Vec<(&str, Value)>) -> Value {
4797        Value::Map(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
4798    }
4799
4800    #[test]
4801    fn test_parse_datetime_utc_accepts_bracketed_timezone_suffix() {
4802        let dt = parse_datetime_utc("2020-01-01T00:00Z[UTC]").unwrap();
4803        assert_eq!(dt.to_rfc3339(), "2020-01-01T00:00:00+00:00");
4804
4805        let dt = parse_datetime_utc("2020-01-01T01:00:00+01:00[Europe/Paris]").unwrap();
4806        assert_eq!(dt.to_rfc3339(), "2020-01-01T00:00:00+00:00");
4807    }
4808
4809    #[test]
4810    fn test_date_from_map_calendar() {
4811        let result = eval_date(&[map_val(vec![
4812            ("year", Value::Int(1984)),
4813            ("month", Value::Int(10)),
4814            ("day", Value::Int(11)),
4815        ])])
4816        .unwrap();
4817        assert_eq!(result.to_string(), "1984-10-11");
4818    }
4819
4820    #[test]
4821    fn test_date_from_map_defaults() {
4822        let result = eval_date(&[map_val(vec![("year", Value::Int(1984))])]).unwrap();
4823        assert_eq!(result.to_string(), "1984-01-01");
4824    }
4825
4826    #[test]
4827    fn test_date_from_week() {
4828        // Week 10, Wednesday (day 3) of 1984
4829        let result = eval_date(&[map_val(vec![
4830            ("year", Value::Int(1984)),
4831            ("week", Value::Int(10)),
4832            ("dayOfWeek", Value::Int(3)),
4833        ])])
4834        .unwrap();
4835        assert!(result.to_string().starts_with("1984-03"));
4836    }
4837
4838    #[test]
4839    fn test_date_from_ordinal() {
4840        // Day 202 of 1984 (leap year)
4841        let result = eval_date(&[map_val(vec![
4842            ("year", Value::Int(1984)),
4843            ("ordinalDay", Value::Int(202)),
4844        ])])
4845        .unwrap();
4846        assert_eq!(result.to_string(), "1984-07-20");
4847    }
4848
4849    #[test]
4850    fn test_date_from_quarter() {
4851        // Q3, day 45 of 1984
4852        let result = eval_date(&[map_val(vec![
4853            ("year", Value::Int(1984)),
4854            ("quarter", Value::Int(3)),
4855            ("dayOfQuarter", Value::Int(45)),
4856        ])])
4857        .unwrap();
4858        assert_eq!(result.to_string(), "1984-08-14");
4859    }
4860
4861    #[test]
4862    fn test_time_from_map() {
4863        let result = eval_time(&[map_val(vec![
4864            ("hour", Value::Int(12)),
4865            ("minute", Value::Int(31)),
4866            ("second", Value::Int(14)),
4867        ])])
4868        .unwrap();
4869        assert_eq!(result.to_string(), "12:31:14Z");
4870    }
4871
4872    #[test]
4873    fn test_time_from_map_with_nanos() {
4874        let result = eval_time(&[map_val(vec![
4875            ("hour", Value::Int(12)),
4876            ("minute", Value::Int(31)),
4877            ("second", Value::Int(14)),
4878            ("millisecond", Value::Int(645)),
4879            ("microsecond", Value::Int(876)),
4880            ("nanosecond", Value::Int(123)),
4881        ])])
4882        .unwrap();
4883        // TemporalValue stores microsecond precision (6 digits), nanos are truncated
4884        assert!(result.to_string().starts_with("12:31:14.645876"));
4885    }
4886
4887    #[test]
4888    fn test_datetime_from_map() {
4889        let result = eval_datetime(&[map_val(vec![
4890            ("year", Value::Int(1984)),
4891            ("month", Value::Int(10)),
4892            ("day", Value::Int(11)),
4893            ("hour", Value::Int(12)),
4894        ])])
4895        .unwrap();
4896        assert!(result.to_string().contains("1984-10-11T12:00"));
4897    }
4898
4899    #[test]
4900    fn test_localdatetime_from_week() {
4901        // Week 1 of 1816 should be 1816-01-01 (Monday of that week)
4902        let result = eval_localdatetime(&[map_val(vec![
4903            ("year", Value::Int(1816)),
4904            ("week", Value::Int(1)),
4905        ])])
4906        .unwrap();
4907        assert_eq!(result.to_string(), "1816-01-01T00:00");
4908
4909        // Week 52 of 1816
4910        let result = eval_localdatetime(&[map_val(vec![
4911            ("year", Value::Int(1816)),
4912            ("week", Value::Int(52)),
4913        ])])
4914        .unwrap();
4915        assert_eq!(result.to_string(), "1816-12-23T00:00");
4916
4917        // Week 1 of 1817 (starts in 1816!)
4918        let result = eval_localdatetime(&[map_val(vec![
4919            ("year", Value::Int(1817)),
4920            ("week", Value::Int(1)),
4921        ])])
4922        .unwrap();
4923        assert_eq!(result.to_string(), "1816-12-30T00:00");
4924    }
4925
4926    #[test]
4927    fn test_duration_from_map_extended() {
4928        let result = eval_duration(&[map_val(vec![
4929            ("years", Value::Int(1)),
4930            ("months", Value::Int(2)),
4931            ("days", Value::Int(3)),
4932        ])])
4933        .unwrap();
4934        // Duration is now returned as Value::Temporal(Duration{...})
4935        let dur_str = result.to_string();
4936        assert!(dur_str.starts_with('P'));
4937        assert!(dur_str.contains('Y')); // Should have years (14 months = 1 year + 2 months)
4938        assert!(dur_str.contains('D')); // Should have days
4939    }
4940
4941    #[test]
4942    fn test_datetime_fromepoch() {
4943        let result = eval_datetime_fromepoch(&[Value::Int(0)]).unwrap();
4944        assert_eq!(result.to_string(), "1970-01-01T00:00Z");
4945    }
4946
4947    #[test]
4948    fn test_datetime_fromepochmillis() {
4949        let result = eval_datetime_fromepochmillis(&[Value::Int(0)]).unwrap();
4950        assert_eq!(result.to_string(), "1970-01-01T00:00Z");
4951    }
4952
4953    #[test]
4954    fn test_truncate_date_year() {
4955        let result = eval_truncate(
4956            "date",
4957            &[
4958                Value::String("year".to_string()),
4959                Value::String("1984-10-11".to_string()),
4960            ],
4961        )
4962        .unwrap();
4963        assert_eq!(result.to_string(), "1984-01-01");
4964    }
4965
4966    #[test]
4967    fn test_truncate_date_month() {
4968        let result = eval_truncate(
4969            "date",
4970            &[
4971                Value::String("month".to_string()),
4972                Value::String("1984-10-11".to_string()),
4973            ],
4974        )
4975        .unwrap();
4976        assert_eq!(result.to_string(), "1984-10-01");
4977    }
4978
4979    #[test]
4980    fn test_truncate_datetime_hour() {
4981        let result = eval_truncate(
4982            "datetime",
4983            &[
4984                Value::String("hour".to_string()),
4985                Value::String("1984-10-11T12:31:14Z".to_string()),
4986            ],
4987        )
4988        .unwrap();
4989        assert!(result.to_string().contains("1984-10-11T12:00"));
4990    }
4991
4992    #[test]
4993    fn test_duration_between() {
4994        let result = eval_duration_between(&[
4995            Value::String("1984-10-11".to_string()),
4996            Value::String("1984-10-12".to_string()),
4997        ])
4998        .unwrap();
4999        assert_eq!(result.to_string(), "P1D");
5000    }
5001
5002    #[test]
5003    fn test_duration_in_days() {
5004        let result = eval_duration_in_days(&[
5005            Value::String("1984-10-11".to_string()),
5006            Value::String("1984-10-21".to_string()),
5007        ])
5008        .unwrap();
5009        assert_eq!(result.to_string(), "P10D");
5010    }
5011
5012    #[test]
5013    fn test_duration_in_months() {
5014        let result = eval_duration_in_months(&[
5015            Value::String("1984-10-11".to_string()),
5016            Value::String("1985-01-11".to_string()),
5017        ])
5018        .unwrap();
5019        assert_eq!(result.to_string(), "P3M");
5020    }
5021
5022    #[test]
5023    fn test_duration_in_seconds() {
5024        let result = eval_duration_in_seconds(&[
5025            Value::String("1984-10-11T12:00:00".to_string()),
5026            Value::String("1984-10-11T13:00:00".to_string()),
5027        ])
5028        .unwrap();
5029        assert_eq!(result.to_string(), "PT1H");
5030    }
5031
5032    #[test]
5033    fn test_classify_temporal() {
5034        assert_eq!(classify_temporal("1984-10-11"), Some(TemporalType::Date));
5035        assert_eq!(classify_temporal("12:31:14"), Some(TemporalType::LocalTime));
5036        assert_eq!(
5037            classify_temporal("12:31:14+01:00"),
5038            Some(TemporalType::Time)
5039        );
5040        assert_eq!(
5041            classify_temporal("1984-10-11T12:31:14"),
5042            Some(TemporalType::LocalDateTime)
5043        );
5044        assert_eq!(
5045            classify_temporal("1984-10-11T12:31:14Z"),
5046            Some(TemporalType::DateTime)
5047        );
5048        assert_eq!(
5049            classify_temporal("1984-10-11T12:31:14+01:00"),
5050            Some(TemporalType::DateTime)
5051        );
5052        assert_eq!(classify_temporal("P1Y2M3D"), Some(TemporalType::Duration));
5053    }
5054
5055    #[test]
5056    fn test_add_months_to_date_clamping() {
5057        // Jan 31 + 1 month = Feb 28 (non-leap year)
5058        let date = NaiveDate::from_ymd_opt(2023, 1, 31).unwrap();
5059        let result = add_months_to_date(date, 1);
5060        assert_eq!(result, NaiveDate::from_ymd_opt(2023, 2, 28).unwrap());
5061
5062        // Jan 31 + 1 month in leap year = Feb 29
5063        let date = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
5064        let result = add_months_to_date(date, 1);
5065        assert_eq!(result, NaiveDate::from_ymd_opt(2024, 2, 29).unwrap());
5066    }
5067
5068    #[test]
5069    fn test_cypher_duration_multiply() {
5070        let dur = CypherDuration::new(1, 1, 0);
5071        let result = dur.multiply(2.0);
5072        assert_eq!(result.months, 2);
5073        assert_eq!(result.days, 2);
5074    }
5075
5076    #[test]
5077    fn test_fractional_cascading_in_map() {
5078        // months: 5.5 cascades via avg Gregorian month (2629746s).
5079        // 0.5 months = 1314873s = 15 days + 18873s = 15d 5h 14m 33s
5080        let result = eval_duration(&[map_val(vec![
5081            ("months", Value::Float(5.5)),
5082            ("days", Value::Int(0)),
5083        ])])
5084        .unwrap();
5085        let s = result.to_string();
5086        assert_eq!(s, "P5M15DT5H14M33S");
5087    }
5088
5089    #[test]
5090    fn test_fractional_cascading_full() {
5091        let result = eval_duration(&[map_val(vec![
5092            ("years", Value::Float(12.5)),
5093            ("months", Value::Float(5.5)),
5094            ("days", Value::Float(14.5)),
5095            ("hours", Value::Float(16.5)),
5096            ("minutes", Value::Float(12.5)),
5097            ("seconds", Value::Float(70.5)),
5098            ("nanoseconds", Value::Int(3)),
5099        ])])
5100        .unwrap();
5101        let s = result.to_string();
5102        // Verify roundtrip
5103        let dur = parse_duration_to_cypher(&s).unwrap();
5104        assert_eq!(dur.months, 155);
5105        assert_eq!(dur.days, 29);
5106    }
5107
5108    #[test]
5109    fn test_parse_iso8601_duration_with_weeks() {
5110        let micros = parse_duration_to_micros("P1W").unwrap();
5111        assert_eq!(micros, 7 * MICROS_PER_DAY);
5112    }
5113
5114    #[test]
5115    fn test_parse_iso8601_duration_complex() {
5116        let micros = parse_duration_to_micros("P1DT2H30M").unwrap();
5117        let expected = MICROS_PER_DAY + 2 * MICROS_PER_HOUR + 30 * MICROS_PER_MINUTE;
5118        assert_eq!(micros, expected);
5119    }
5120}