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