Skip to main content

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