"""
Temporal types for Cypher compatibility.

Implements: date, time, localtime, datetime, localdatetime, duration

Uses NumPy datetime64 for extended date range support (-999999999 to +999999999 years).
"""

from datetime import date, time, datetime, timedelta, timezone as dt_timezone
from typing import Union, Optional, Dict, Any, Tuple
import re
from dataclasses import dataclass
import numpy as np

try:
    from zoneinfo import ZoneInfo
except ImportError:
    # Python < 3.9 fallback
    try:
        from backports.zoneinfo import ZoneInfo
    except ImportError:
        ZoneInfo = None  # type: ignore

# Sentinel for distinguishing "no argument" from "explicit null" in temporal functions
_UNSET = object()


class QueryClock:
    """
    Manages temporal clocks for query execution with three consistency levels:

    - statement: Same value within a single Cypher statement
    - transaction: Same value within an entire transaction
    - realtime: Fresh value on each call

    Usage:
        clock = QueryClock()
        clock.start_transaction()  # Call once at transaction start
        clock.start_statement()    # Call at each statement start

        # Then use:
        clock.statement_time  # Frozen at statement start
        clock.transaction_time  # Frozen at transaction start
        clock.realtime  # Always current
    """

    def __init__(self):
        self._transaction_time: Optional[datetime] = None
        self._statement_time: Optional[datetime] = None

    def start_transaction(self):
        """Capture the transaction start time. Call once per transaction."""
        self._transaction_time = datetime.now()
        # Also initialize statement time (first statement starts with transaction)
        self._statement_time = self._transaction_time

    def start_statement(self):
        """Capture the statement start time. Call at start of each statement."""
        self._statement_time = datetime.now()

    @property
    def statement_time(self) -> datetime:
        """Get the statement-scoped time (frozen at statement start)."""
        if self._statement_time is None:
            # Lazy initialization if not explicitly started
            self._statement_time = datetime.now()
        return self._statement_time

    @property
    def transaction_time(self) -> datetime:
        """Get the transaction-scoped time (frozen at transaction start)."""
        if self._transaction_time is None:
            # Lazy initialization if not explicitly started
            self._transaction_time = datetime.now()
        return self._transaction_time

    @property
    def realtime(self) -> datetime:
        """Get the current realtime (fresh on each call)."""
        return datetime.now()


def _parse_timezone(tz_str: str) -> Tuple[timedelta, Optional[str]]:
    """Parse timezone string and return (offset, timezone_name).

    Handles:
    - 'Z' -> (timedelta(0), None)
    - '+01:00' -> (timedelta(hours=1), None)
    - '+01:00:00' -> (timedelta(hours=1), None)  # with seconds
    - '+02:05:59' -> (timedelta(hours=2, minutes=5, seconds=59), None)
    - '+0100' -> (timedelta(hours=1), None)  # compact format
    - 'Europe/Stockholm' -> (computed_offset, 'Europe/Stockholm')
    """
    if tz_str == 'Z':
        return timedelta(0), None

    if '+' in tz_str or tz_str.startswith('-'):
        # Offset timezone
        sign = 1 if '+' in tz_str else -1
        tz = tz_str.replace('+', '').replace('-', '')

        # Check if it's compact format (no colons): +0100 or +010059
        if ':' not in tz:
            if len(tz) == 4:  # HHMM
                hours = int(tz[:2])
                minutes = int(tz[2:4])
                seconds = 0
            elif len(tz) == 6:  # HHMMSS
                hours = int(tz[:2])
                minutes = int(tz[2:4])
                seconds = int(tz[4:6])
            elif len(tz) == 2:  # HH only
                hours = int(tz[:2])
                minutes = 0
                seconds = 0
            else:
                hours, minutes, seconds = 0, 0, 0
        else:
            # Standard format with colons
            parts = tz.split(':')
            hours = int(parts[0]) if parts else 0
            minutes = int(parts[1]) if len(parts) > 1 else 0
            seconds = int(parts[2]) if len(parts) > 2 else 0

        return timedelta(hours=sign * hours, minutes=sign * minutes, seconds=sign * seconds), None

    # Named timezone (e.g., 'Europe/Stockholm')
    if ZoneInfo is not None:
        try:
            # Return offset=0 for now, but store the timezone name
            # The actual offset will be computed based on the datetime
            return timedelta(0), tz_str
        except Exception:
            pass

    return timedelta(0), None


def _add_months_to_date(d: date, months: int) -> date:
    """Add months to a date, handling overflow (e.g., Jan 31 + 1 month = Feb 28/29)."""
    if months == 0:
        return d

    # Calculate new year and month
    total_months = d.year * 12 + (d.month - 1) + months
    new_year = total_months // 12
    new_month = (total_months % 12) + 1

    # Handle day overflow (e.g., Jan 31 + 1 month in non-leap year)
    import calendar
    max_day = calendar.monthrange(new_year, new_month)[1]
    new_day = min(d.day, max_day)

    return date(new_year, new_month, new_day)


def _add_months_extended(year: int, month: int, day: int, months: int) -> Tuple[int, int, int]:
    """Add months to a date with extended year range support.

    Returns (year, month, day) tuple.
    """
    if months == 0:
        return (year, month, day)

    # Calculate new year and month
    total_months = year * 12 + (month - 1) + months
    new_year = total_months // 12
    new_month = (total_months % 12) + 1

    # Handle negative total_months
    if new_month <= 0:
        new_month += 12
        new_year -= 1

    # Handle day overflow (e.g., Jan 31 + 1 month in non-leap year)
    max_day = _days_in_month_extended(new_year, new_month)
    new_day = min(day, max_day)

    return (new_year, new_month, new_day)


def _days_in_month_extended(year: int, month: int) -> int:
    """Get the number of days in a month for extended year range."""
    if month in (1, 3, 5, 7, 8, 10, 12):
        return 31
    elif month in (4, 6, 9, 11):
        return 30
    elif month == 2:
        return 29 if _is_leap_year_extended(year) else 28
    else:
        raise ValueError(f"Invalid month: {month}")


def _is_leap_year_extended(year: int) -> bool:
    """Check if a year is a leap year (works for extended year range)."""
    # Proleptic Gregorian calendar rules
    if year % 400 == 0:
        return True
    if year % 100 == 0:
        return False
    if year % 4 == 0:
        return True
    return False


@dataclass
class CypherDate:
    """Cypher date type with extended year range support (-999999999 to +999999999).

    Uses NumPy datetime64 internally for extended range and arithmetic operations.
    """
    _year: int
    _month: int
    _day: int
    _np_date: np.datetime64  # NumPy datetime64 for extended range support

    def __init__(self, d: Union[date, Tuple[int, int, int], None] = None, *, year: int = None, month: int = None, day: int = None):
        """Initialize from a date object, tuple (year, month, day), or keyword arguments."""
        if d is not None:
            if isinstance(d, date):
                self._year = d.year
                self._month = d.month
                self._day = d.day
            elif isinstance(d, tuple) and len(d) == 3:
                self._year, self._month, self._day = d
            else:
                raise TypeError(f"Expected date or (year, month, day) tuple, got {type(d)}")
        elif year is not None:
            self._year = year
            self._month = month if month is not None else 1
            self._day = day if day is not None else 1
        else:
            raise ValueError("Must provide either a date object, tuple, or year/month/day")

        # Create NumPy datetime64 for extended range operations
        self._np_date = self._to_numpy()

    def _to_numpy(self) -> np.datetime64:
        """Convert to NumPy datetime64."""
        if self._year >= 0:
            date_str = f'{self._year:04d}-{self._month:02d}-{self._day:02d}'
        else:
            date_str = f'{self._year:05d}-{self._month:02d}-{self._day:02d}'
        return np.datetime64(date_str, 'D')

    @classmethod
    def from_numpy(cls, np_date: np.datetime64) -> 'CypherDate':
        """Create CypherDate from NumPy datetime64."""
        # Convert to string and parse
        date_str = str(np_date)
        # Handle formats like "-999999999-01-01" or "2024-01-01"
        if date_str.startswith('-'):
            # Negative year
            parts = date_str[1:].split('-')
            year = -int(parts[0])
            month = int(parts[1])
            day = int(parts[2])
        else:
            parts = date_str.split('-')
            year = int(parts[0])
            month = int(parts[1])
            day = int(parts[2])
        return cls(year=year, month=month, day=day)

    @property
    def year(self) -> int:
        return self._year

    @property
    def month(self) -> int:
        return self._month

    @property
    def day(self) -> int:
        return self._day

    @property
    def week(self) -> int:
        """ISO week number."""
        # For dates in Python's range, use the built-in
        if 1 <= self._year <= 9999:
            return date(self._year, self._month, self._day).isocalendar()[1]
        # For extended range, calculate manually
        return self._calculate_iso_week()[1]

    @property
    def weekYear(self) -> int:
        """ISO week year (can differ from calendar year for dates in week 1/52/53)."""
        if 1 <= self._year <= 9999:
            return date(self._year, self._month, self._day).isocalendar()[0]
        return self._calculate_iso_week()[0]

    def _calculate_iso_week(self) -> Tuple[int, int, int]:
        """Calculate ISO week year, week number, and day of week for extended range dates."""
        # ISO 8601: Week 1 is the week containing the first Thursday of the year
        # Day of week: Monday=1, Sunday=7
        dow = self.dayOfWeek

        # Calculate the Thursday of this week
        days_to_thursday = 4 - dow  # Thursday is day 4
        thursday = self._np_date + np.timedelta64(days_to_thursday, 'D')
        thursday_str = str(thursday)

        # Parse the Thursday's year
        if thursday_str.startswith('-'):
            parts = thursday_str[1:].split('-')
            thursday_year = -int(parts[0])
        else:
            parts = thursday_str.split('-')
            thursday_year = int(parts[0])

        # Week number is based on how many weeks since Jan 1 of the Thursday's year
        jan1 = np.datetime64(f'{thursday_year:04d}-01-01' if thursday_year >= 0 else f'{thursday_year:05d}-01-01', 'D')
        jan1_dow = ((jan1.astype('datetime64[D]').astype(int) + 3) % 7) + 1  # 1=Mon, 7=Sun

        # First Thursday of the year
        days_to_first_thursday = (4 - jan1_dow) % 7
        first_thursday = jan1 + np.timedelta64(days_to_first_thursday, 'D')

        # Week number
        days_diff = (thursday - first_thursday).astype(int)
        week_num = (days_diff // 7) + 1

        return (thursday_year, week_num, dow)

    @property
    def dayOfWeek(self) -> int:
        """ISO day of week (1=Monday, 7=Sunday)."""
        # NumPy datetime64 epoch is 1970-01-01 which was a Thursday (day 4)
        # Calculate day of week from the NumPy date
        days_since_epoch = self._np_date.astype('datetime64[D]').astype(int)
        return ((days_since_epoch + 3) % 7) + 1  # +3 because 1970-01-01 was Thursday (day 4)

    @property
    def weekDay(self) -> int:
        """Alias for dayOfWeek."""
        return self.dayOfWeek

    @property
    def dayOfYear(self) -> int:
        """Day of year (1-366)."""
        jan1 = np.datetime64(f'{self._year:04d}-01-01' if self._year >= 0 else f'{self._year:05d}-01-01', 'D')
        return int((self._np_date - jan1).astype(int)) + 1

    @property
    def ordinalDay(self) -> int:
        """Alias for dayOfYear."""
        return self.dayOfYear

    @property
    def quarter(self) -> int:
        return (self._month - 1) // 3 + 1

    @property
    def dayOfQuarter(self) -> int:
        """Day within the current quarter (1-92)."""
        quarter_start_month = ((self.quarter - 1) * 3) + 1
        quarter_start = np.datetime64(
            f'{self._year:04d}-{quarter_start_month:02d}-01' if self._year >= 0
            else f'{self._year:05d}-{quarter_start_month:02d}-01', 'D'
        )
        return int((self._np_date - quarter_start).astype(int)) + 1

    def __str__(self) -> str:
        """ISO 8601 format with extended year support."""
        if self._year >= 0 and self._year <= 9999:
            return f'{self._year:04d}-{self._month:02d}-{self._day:02d}'
        elif self._year > 9999:
            return f'+{self._year}-{self._month:02d}-{self._day:02d}'
        else:
            return f'{self._year:05d}-{self._month:02d}-{self._day:02d}'

    def __repr__(self) -> str:
        return f"date('{self}')"

    def __eq__(self, other):
        if isinstance(other, CypherDate):
            return self._np_date == other._np_date
        if isinstance(other, str):
            return str(self) == other
        return False

    def __hash__(self):
        return hash((self._year, self._month, self._day))

    def __lt__(self, other):
        if isinstance(other, CypherDate):
            return self._np_date < other._np_date
        return NotImplemented

    def __le__(self, other):
        if isinstance(other, CypherDate):
            return self._np_date <= other._np_date
        return NotImplemented

    def __gt__(self, other):
        if isinstance(other, CypherDate):
            return self._np_date > other._np_date
        return NotImplemented

    def __ge__(self, other):
        if isinstance(other, CypherDate):
            return self._np_date >= other._np_date
        return NotImplemented

    def __add__(self, other):
        if isinstance(other, CypherDuration):
            # First add months (calendar-based)
            new_year, new_month, new_day = _add_months_extended(
                self._year, self._month, self._day, other.months
            )
            # Calculate total days including overflow from time components
            total_seconds = other.seconds + other._nanoseconds / 1_000_000_000
            extra_days_from_time = int(total_seconds // 86400)
            total_days = other.days + extra_days_from_time
            # Create new date and add days using NumPy
            new_date = CypherDate(year=new_year, month=new_month, day=new_day)
            if total_days != 0:
                result_np = new_date._np_date + np.timedelta64(total_days, 'D')
                return CypherDate.from_numpy(result_np)
            return new_date
        return NotImplemented

    def __sub__(self, other):
        if isinstance(other, CypherDuration):
            # First subtract months (calendar-based)
            new_year, new_month, new_day = _add_months_extended(
                self._year, self._month, self._day, -other.months
            )
            # Calculate total days including overflow from time components
            total_seconds = other.seconds + other._nanoseconds / 1_000_000_000
            extra_days_from_time = int(total_seconds // 86400)
            total_days = other.days + extra_days_from_time
            # Create new date and subtract days using NumPy
            new_date = CypherDate(year=new_year, month=new_month, day=new_day)
            if total_days != 0:
                result_np = new_date._np_date - np.timedelta64(total_days, 'D')
                return CypherDate.from_numpy(result_np)
            return new_date
        if isinstance(other, CypherDate):
            # Use NumPy for extended range subtraction
            delta_days = int((self._np_date - other._np_date).astype(int))
            return CypherDuration(days=delta_days)
        return NotImplemented

    def to_python_date(self) -> Optional[date]:
        """Convert to Python date if within supported range (1-9999), else None."""
        if 1 <= self._year <= 9999:
            return date(self._year, self._month, self._day)
        return None

    @property
    def _date(self) -> date:
        """Backward compatibility property. Returns Python date for standard range.

        Warning: Raises ValueError for dates outside Python's 1-9999 range.
        For extended range, use year/month/day properties or _np_date directly.
        """
        if 1 <= self._year <= 9999:
            return date(self._year, self._month, self._day)
        raise ValueError(f"Year {self._year} is outside Python date range (1-9999). "
                        "Use year/month/day properties or _np_date for extended range.")


@dataclass
class CypherLocalTime:
    """Cypher localtime type (no timezone)."""
    _time: time
    _nanosecond: int = 0  # Full nanosecond value (0-999999999)

    def __init__(self, t: time, nanosecond: int = 0):
        self._time = t
        # Store full nanosecond value, but datetime only stores up to microseconds
        # If nanosecond is provided, use it; otherwise derive from time's microseconds
        if nanosecond > 0:
            self._nanosecond = nanosecond
        else:
            self._nanosecond = t.microsecond * 1000

    @property
    def hour(self) -> int:
        return self._time.hour

    @property
    def minute(self) -> int:
        return self._time.minute

    @property
    def second(self) -> int:
        return self._time.second

    @property
    def millisecond(self) -> int:
        return self._nanosecond // 1_000_000

    @property
    def microsecond(self) -> int:
        return self._nanosecond // 1000

    @property
    def nanosecond(self) -> int:
        return self._nanosecond

    def __str__(self) -> str:
        if self._nanosecond:
            # Format with fractional seconds
            base = self._time.strftime('%H:%M:%S')
            frac = f'{self._nanosecond:09d}'.rstrip('0')
            return f'{base}.{frac}'
        elif self._time.second:
            # Has seconds but no fractional part
            return self._time.strftime('%H:%M:%S')
        else:
            # No seconds - omit :00
            return self._time.strftime('%H:%M')

    def __repr__(self) -> str:
        return f"localtime('{self}')"

    def __eq__(self, other):
        if isinstance(other, CypherLocalTime):
            return self._time == other._time and self._nanosecond == other._nanosecond
        if isinstance(other, str):
            return str(self) == other
        return False

    def __lt__(self, other):
        if isinstance(other, CypherLocalTime):
            if self._time == other._time:
                return self._nanosecond < other._nanosecond
            return self._time < other._time
        return NotImplemented

    def __le__(self, other):
        if isinstance(other, CypherLocalTime):
            if self._time == other._time:
                return self._nanosecond <= other._nanosecond
            return self._time <= other._time
        return NotImplemented

    def __gt__(self, other):
        if isinstance(other, CypherLocalTime):
            if self._time == other._time:
                return self._nanosecond > other._nanosecond
            return self._time > other._time
        return NotImplemented

    def __ge__(self, other):
        if isinstance(other, CypherLocalTime):
            if self._time == other._time:
                return self._nanosecond >= other._nanosecond
            return self._time >= other._time
        return NotImplemented

    def __add__(self, other):
        """Add a duration to this localtime."""
        if isinstance(other, CypherDuration):
            # Convert to total seconds for calculation
            total_secs = (self._time.hour * 3600 + self._time.minute * 60 +
                          self._time.second + other.days * 86400 + other.seconds)
            total_ns = self._nanosecond + other._nanoseconds

            # Handle nanosecond overflow
            if total_ns >= 1_000_000_000:
                total_secs += total_ns // 1_000_000_000
                total_ns = total_ns % 1_000_000_000

            # Normalize to 24-hour cycle
            total_secs = total_secs % 86400
            if total_secs < 0:
                total_secs += 86400

            hours = int(total_secs // 3600)
            mins = int((total_secs % 3600) // 60)
            secs = int(total_secs % 60)
            microsecs = total_ns // 1000

            new_time = time(hours, mins, secs, microsecs)
            return CypherLocalTime(new_time, total_ns)
        return NotImplemented

    def __radd__(self, other):
        return self.__add__(other)

    def __sub__(self, other):
        """Subtract a duration from this localtime."""
        if isinstance(other, CypherDuration):
            neg_duration = CypherDuration(
                months=-other.months,
                days=-other.days,
                seconds=-other.seconds,
                nanoseconds=-other._nanoseconds
            )
            return self.__add__(neg_duration)
        return NotImplemented


@dataclass
class CypherTime:
    """Cypher time type (with timezone)."""
    _time: time
    _offset: Optional[timedelta] = None
    _nanosecond: int = 0  # Full nanosecond value (0-999999999)

    def __init__(self, t: time, offset: Optional[timedelta] = None, nanosecond: int = 0):
        self._time = t
        self._offset = offset or timedelta(0)
        # Store full nanosecond value
        if nanosecond > 0:
            self._nanosecond = nanosecond
        else:
            self._nanosecond = t.microsecond * 1000

    @property
    def hour(self) -> int:
        return self._time.hour

    @property
    def minute(self) -> int:
        return self._time.minute

    @property
    def second(self) -> int:
        return self._time.second

    @property
    def millisecond(self) -> int:
        return self._nanosecond // 1_000_000

    @property
    def microsecond(self) -> int:
        return self._nanosecond // 1000

    @property
    def nanosecond(self) -> int:
        return self._nanosecond

    @property
    def offset(self) -> str:
        if self._offset is None or self._offset == timedelta(0):
            return "Z"
        total_seconds = int(self._offset.total_seconds())
        hours, remainder = divmod(abs(total_seconds), 3600)
        minutes = remainder // 60
        seconds = remainder % 60
        sign = '+' if total_seconds >= 0 else '-'
        if seconds:
            return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}"
        return f"{sign}{hours:02d}:{minutes:02d}"

    @property
    def timezone(self) -> str:
        """Return the timezone offset string."""
        return self.offset

    @property
    def offsetMinutes(self) -> int:
        """Return the timezone offset in minutes."""
        if self._offset is None:
            return 0
        return int(self._offset.total_seconds() // 60)

    @property
    def offsetSeconds(self) -> int:
        """Return the timezone offset in seconds."""
        if self._offset is None:
            return 0
        return int(self._offset.total_seconds())

    def __str__(self) -> str:
        if self._nanosecond:
            # Format with fractional seconds
            base = self._time.strftime('%H:%M:%S')
            frac = f'{self._nanosecond:09d}'.rstrip('0')
            return f'{base}.{frac}{self.offset}'
        elif self._time.second:
            # Has seconds but no fractional part
            return self._time.strftime('%H:%M:%S') + self.offset
        else:
            # No seconds - omit :00
            return self._time.strftime('%H:%M') + self.offset

    def __repr__(self) -> str:
        return f"time('{self}')"

    def __eq__(self, other):
        if isinstance(other, CypherTime):
            return self._time == other._time and self._offset == other._offset and self._nanosecond == other._nanosecond
        if isinstance(other, str):
            return str(self) == other
        return False

    def __lt__(self, other):
        if isinstance(other, CypherTime):
            # Compare by converting to UTC equivalent
            self_total_seconds = self._time.hour * 3600 + self._time.minute * 60 + self._time.second
            other_total_seconds = other._time.hour * 3600 + other._time.minute * 60 + other._time.second
            # Adjust for timezone offsets
            self_utc = self_total_seconds - self._offset.total_seconds()
            other_utc = other_total_seconds - other._offset.total_seconds()
            if self_utc == other_utc:
                return self._nanosecond < other._nanosecond
            return self_utc < other_utc
        return NotImplemented

    def __le__(self, other):
        if isinstance(other, CypherTime):
            return self == other or self < other
        return NotImplemented

    def __gt__(self, other):
        if isinstance(other, CypherTime):
            return not (self <= other)
        return NotImplemented

    def __ge__(self, other):
        if isinstance(other, CypherTime):
            return not (self < other)
        return NotImplemented

    def __add__(self, other):
        """Add a duration to this time."""
        if isinstance(other, CypherDuration):
            # Convert to total seconds for calculation
            total_secs = (self._time.hour * 3600 + self._time.minute * 60 +
                          self._time.second + other.days * 86400 + other.seconds)
            total_ns = self._nanosecond + other._nanoseconds

            # Handle nanosecond overflow
            if total_ns >= 1_000_000_000:
                total_secs += total_ns // 1_000_000_000
                total_ns = total_ns % 1_000_000_000

            # Normalize to 24-hour cycle
            total_secs = total_secs % 86400
            if total_secs < 0:
                total_secs += 86400

            hours = int(total_secs // 3600)
            mins = int((total_secs % 3600) // 60)
            secs = int(total_secs % 60)
            microsecs = total_ns // 1000

            new_time = time(hours, mins, secs, microsecs)
            return CypherTime(new_time, self._offset, total_ns)
        return NotImplemented

    def __radd__(self, other):
        return self.__add__(other)

    def __sub__(self, other):
        """Subtract a duration from this time."""
        if isinstance(other, CypherDuration):
            neg_duration = CypherDuration(
                months=-other.months,
                days=-other.days,
                seconds=-other.seconds,
                nanoseconds=-other._nanoseconds
            )
            return self.__add__(neg_duration)
        return NotImplemented


class CypherLocalDateTime:
    """Cypher localdatetime type (no timezone) with extended year range support."""
    _year: int
    _month: int
    _day: int
    _hour: int
    _minute: int
    _second: int
    _nanosecond: int
    _np_datetime: np.datetime64  # NumPy datetime64 for extended range

    def __init__(self, dt: Union[datetime, None] = None, nanosecond: int = 0, *,
                 year: int = None, month: int = None, day: int = None,
                 hour: int = 0, minute: int = 0, second: int = 0):
        """Initialize from datetime or components."""
        if dt is not None:
            self._year = dt.year
            self._month = dt.month
            self._day = dt.day
            self._hour = dt.hour
            self._minute = dt.minute
            self._second = dt.second
            self._nanosecond = nanosecond if nanosecond > 0 else dt.microsecond * 1000
        elif year is not None:
            self._year = year
            self._month = month if month is not None else 1
            self._day = day if day is not None else 1
            self._hour = hour
            self._minute = minute
            self._second = second
            self._nanosecond = nanosecond
        else:
            raise ValueError("Must provide either datetime or year")
        self._np_datetime = self._to_numpy()

    def _to_numpy(self) -> np.datetime64:
        """Convert to NumPy datetime64."""
        if self._year >= 0:
            dt_str = f'{self._year:04d}-{self._month:02d}-{self._day:02d}T{self._hour:02d}:{self._minute:02d}:{self._second:02d}'
        else:
            dt_str = f'{self._year:05d}-{self._month:02d}-{self._day:02d}T{self._hour:02d}:{self._minute:02d}:{self._second:02d}'
        return np.datetime64(dt_str, 's')

    @classmethod
    def from_components(cls, year: int, month: int, day: int,
                       hour: int = 0, minute: int = 0, second: int = 0,
                       nanosecond: int = 0) -> 'CypherLocalDateTime':
        """Create from individual components."""
        return cls(year=year, month=month, day=day, hour=hour, minute=minute,
                  second=second, nanosecond=nanosecond)

    @property
    def year(self) -> int:
        return self._year

    @property
    def month(self) -> int:
        return self._month

    @property
    def day(self) -> int:
        return self._day

    @property
    def hour(self) -> int:
        return self._hour

    @property
    def minute(self) -> int:
        return self._minute

    @property
    def second(self) -> int:
        return self._second

    @property
    def millisecond(self) -> int:
        return self._nanosecond // 1_000_000

    @property
    def microsecond(self) -> int:
        return self._nanosecond // 1000

    @property
    def nanosecond(self) -> int:
        return self._nanosecond

    @property
    def week(self) -> int:
        if 1 <= self._year <= 9999:
            return date(self._year, self._month, self._day).isocalendar()[1]
        # For extended range, use CypherDate's calculation
        d = CypherDate(year=self._year, month=self._month, day=self._day)
        return d.week

    @property
    def weekYear(self) -> int:
        """ISO week year."""
        if 1 <= self._year <= 9999:
            return date(self._year, self._month, self._day).isocalendar()[0]
        d = CypherDate(year=self._year, month=self._month, day=self._day)
        return d.weekYear

    @property
    def dayOfWeek(self) -> int:
        d = CypherDate(year=self._year, month=self._month, day=self._day)
        return d.dayOfWeek

    @property
    def weekDay(self) -> int:
        """Alias for dayOfWeek."""
        return self.dayOfWeek

    @property
    def dayOfYear(self) -> int:
        d = CypherDate(year=self._year, month=self._month, day=self._day)
        return d.dayOfYear

    @property
    def ordinalDay(self) -> int:
        """Alias for dayOfYear."""
        return self.dayOfYear

    @property
    def quarter(self) -> int:
        return (self._month - 1) // 3 + 1

    @property
    def dayOfQuarter(self) -> int:
        """Day within the current quarter (1-92)."""
        d = CypherDate(year=self._year, month=self._month, day=self._day)
        return d.dayOfQuarter

    def __str__(self) -> str:
        if self._year >= 0 and self._year <= 9999:
            year_str = f'{self._year:04d}'
        elif self._year > 9999:
            year_str = f'+{self._year}'
        else:
            year_str = f'{self._year:05d}'

        base = f'{year_str}-{self._month:02d}-{self._day:02d}T{self._hour:02d}:{self._minute:02d}:{self._second:02d}'
        if self._nanosecond:
            frac = f'{self._nanosecond:09d}'.rstrip('0')
            return f'{base}.{frac}'
        elif self._second:
            return base
        else:
            return f'{year_str}-{self._month:02d}-{self._day:02d}T{self._hour:02d}:{self._minute:02d}'

    def __repr__(self) -> str:
        return f"localdatetime('{self}')"

    def __eq__(self, other):
        if isinstance(other, CypherLocalDateTime):
            return (self._np_datetime == other._np_datetime and
                    self._nanosecond == other._nanosecond)
        if isinstance(other, str):
            return str(self) == other
        return False

    def __hash__(self):
        return hash((self._year, self._month, self._day, self._hour,
                    self._minute, self._second, self._nanosecond))

    def __lt__(self, other):
        if isinstance(other, CypherLocalDateTime):
            if self._np_datetime == other._np_datetime:
                return self._nanosecond < other._nanosecond
            return self._np_datetime < other._np_datetime
        return NotImplemented

    def __le__(self, other):
        if isinstance(other, CypherLocalDateTime):
            if self._np_datetime == other._np_datetime:
                return self._nanosecond <= other._nanosecond
            return self._np_datetime <= other._np_datetime
        return NotImplemented

    def __gt__(self, other):
        if isinstance(other, CypherLocalDateTime):
            if self._np_datetime == other._np_datetime:
                return self._nanosecond > other._nanosecond
            return self._np_datetime > other._np_datetime
        return NotImplemented

    def __ge__(self, other):
        if isinstance(other, CypherLocalDateTime):
            if self._np_datetime == other._np_datetime:
                return self._nanosecond >= other._nanosecond
            return self._np_datetime >= other._np_datetime
        return NotImplemented

    def __add__(self, other):
        if isinstance(other, CypherDuration):
            # First add months (calendar-based)
            new_year, new_month, new_day = _add_months_extended(
                self._year, self._month, self._day, other.months
            )
            # Handle nanoseconds with carry
            new_nanos = self._nanosecond + other._nanoseconds
            extra_seconds = new_nanos // 1_000_000_000
            new_nanos = new_nanos % 1_000_000_000
            # Calculate total seconds to add
            total_seconds = other.days * 86400 + other.seconds + extra_seconds
            # Create base numpy datetime and add seconds
            base_dt = CypherLocalDateTime.from_components(
                new_year, new_month, new_day, self._hour, self._minute, self._second
            )
            result_np = base_dt._np_datetime + np.timedelta64(total_seconds, 's')
            # Parse result back to components
            return CypherLocalDateTime._from_numpy(result_np, new_nanos)
        return NotImplemented

    def __sub__(self, other):
        if isinstance(other, CypherDuration):
            # First subtract months (calendar-based)
            new_year, new_month, new_day = _add_months_extended(
                self._year, self._month, self._day, -other.months
            )
            # Handle nanoseconds with borrow
            new_nanos = self._nanosecond - other._nanoseconds
            borrow_seconds = 0
            if new_nanos < 0:
                borrow_seconds = (-new_nanos - 1) // 1_000_000_000 + 1
                new_nanos += borrow_seconds * 1_000_000_000
            # Calculate total seconds to subtract
            total_seconds = other.days * 86400 + other.seconds + borrow_seconds
            # Create base numpy datetime and subtract seconds
            base_dt = CypherLocalDateTime.from_components(
                new_year, new_month, new_day, self._hour, self._minute, self._second
            )
            result_np = base_dt._np_datetime - np.timedelta64(total_seconds, 's')
            return CypherLocalDateTime._from_numpy(result_np, new_nanos)
        if isinstance(other, CypherLocalDateTime):
            # Use NumPy for extended range subtraction
            delta_seconds = int((self._np_datetime - other._np_datetime).astype('timedelta64[s]').astype(int))
            nano_diff = self._nanosecond - other._nanosecond
            if nano_diff < 0:
                delta_seconds -= 1
                nano_diff += 1_000_000_000
            return CypherDuration(seconds=delta_seconds, nanoseconds=nano_diff)
        return NotImplemented

    @classmethod
    def _from_numpy(cls, np_dt: np.datetime64, nanosecond: int = 0) -> 'CypherLocalDateTime':
        """Create from NumPy datetime64."""
        dt_str = str(np_dt)
        # Parse format like "-999999999-01-01T00:00:00" or "2024-01-15T12:30:45"
        if dt_str.startswith('-'):
            # Negative year
            parts = dt_str[1:].split('T')
            date_parts = parts[0].split('-')
            year = -int(date_parts[0])
        else:
            parts = dt_str.split('T')
            date_parts = parts[0].split('-')
            year = int(date_parts[0])
        month = int(date_parts[1])
        day = int(date_parts[2])
        if len(parts) > 1:
            time_parts = parts[1].split(':')
            hour = int(time_parts[0])
            minute = int(time_parts[1]) if len(time_parts) > 1 else 0
            second = int(time_parts[2].split('.')[0]) if len(time_parts) > 2 else 0
        else:
            hour, minute, second = 0, 0, 0
        return cls.from_components(year, month, day, hour, minute, second, nanosecond)

    @property
    def _datetime(self) -> datetime:
        """Backward compatibility property. Returns Python datetime for standard range."""
        if 1 <= self._year <= 9999:
            return datetime(self._year, self._month, self._day,
                           self._hour, self._minute, self._second,
                           self._nanosecond // 1000)
        raise ValueError(f"Year {self._year} is outside Python datetime range (1-9999)")


@dataclass
class CypherDateTime:
    """Cypher datetime type (with timezone)."""
    _datetime: datetime
    _offset: Optional[timedelta] = None
    _nanosecond: int = 0  # Full nanosecond value (0-999999999)
    _tz_name: Optional[str] = None  # Named timezone (e.g., 'Europe/Stockholm')

    def __init__(self, dt: datetime, offset: Optional[timedelta] = None, nanosecond: int = 0,
                 tz_name: Optional[str] = None):
        self._datetime = dt
        self._tz_name = tz_name
        # Compute offset from named timezone if provided
        if tz_name and ZoneInfo is not None:
            try:
                tz_info = ZoneInfo(tz_name)
                # Use the datetime to get the correct offset (handles DST)
                aware_dt = dt.replace(tzinfo=tz_info)
                self._offset = aware_dt.utcoffset() or timedelta(0)
            except Exception:
                self._offset = offset or timedelta(0)
        else:
            self._offset = offset or timedelta(0)
        # Store full nanosecond value
        if nanosecond > 0:
            self._nanosecond = nanosecond
        else:
            self._nanosecond = dt.microsecond * 1000

    @property
    def year(self) -> int:
        return self._datetime.year

    @property
    def month(self) -> int:
        return self._datetime.month

    @property
    def day(self) -> int:
        return self._datetime.day

    @property
    def hour(self) -> int:
        return self._datetime.hour

    @property
    def minute(self) -> int:
        return self._datetime.minute

    @property
    def second(self) -> int:
        return self._datetime.second

    @property
    def offset(self) -> str:
        if self._offset is None or self._offset == timedelta(0):
            return "Z"
        total_seconds = int(self._offset.total_seconds())
        hours, remainder = divmod(abs(total_seconds), 3600)
        minutes = remainder // 60
        seconds = remainder % 60
        sign = '+' if total_seconds >= 0 else '-'
        if seconds:
            return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}"
        return f"{sign}{hours:02d}:{minutes:02d}"

    @property
    def epochSeconds(self) -> int:
        """Return seconds since Unix epoch (1970-01-01T00:00:00Z).

        The stored datetime represents local time at the given offset.
        To get UTC, we subtract the offset from local time.
        """
        # Create a UTC datetime from the stored local time and offset
        utc_dt = self._datetime - (self._offset or timedelta(0))
        # Convert to epoch seconds (use 1970 epoch)
        epoch = datetime(1970, 1, 1)
        return int((utc_dt - epoch).total_seconds())

    @property
    def epochMillis(self) -> int:
        """Return milliseconds since Unix epoch (1970-01-01T00:00:00Z)."""
        # Use epochSeconds (which doesn't include sub-second precision) and add milliseconds from nanoseconds
        return self.epochSeconds * 1000 + (self._nanosecond // 1_000_000)

    @property
    def millisecond(self) -> int:
        return self._nanosecond // 1_000_000

    @property
    def microsecond(self) -> int:
        return self._nanosecond // 1000

    @property
    def nanosecond(self) -> int:
        return self._nanosecond

    @property
    def week(self) -> int:
        return self._datetime.isocalendar()[1]

    @property
    def weekYear(self) -> int:
        """ISO week year (can differ from calendar year for dates in week 1/52/53)."""
        return self._datetime.isocalendar()[0]

    @property
    def dayOfWeek(self) -> int:
        return self._datetime.isoweekday()

    @property
    def weekDay(self) -> int:
        """Alias for dayOfWeek."""
        return self._datetime.isoweekday()

    @property
    def dayOfYear(self) -> int:
        return self._datetime.timetuple().tm_yday

    @property
    def ordinalDay(self) -> int:
        """Alias for dayOfYear."""
        return self._datetime.timetuple().tm_yday

    @property
    def quarter(self) -> int:
        return (self._datetime.month - 1) // 3 + 1

    @property
    def dayOfQuarter(self) -> int:
        """Day within the current quarter (1-92)."""
        quarter_start_month = ((self.quarter - 1) * 3) + 1
        quarter_start = date(self._datetime.year, quarter_start_month, 1)
        delta = self._datetime.date() - quarter_start
        return delta.days + 1

    @property
    def timezone(self) -> Optional[str]:
        """Return the timezone name if set, otherwise None."""
        return self._tz_name

    @property
    def offsetMinutes(self) -> int:
        """Return the timezone offset in minutes."""
        if self._offset is None:
            return 0
        return int(self._offset.total_seconds() // 60)

    @property
    def offsetSeconds(self) -> int:
        """Return the timezone offset in seconds."""
        if self._offset is None:
            return 0
        return int(self._offset.total_seconds())

    def __str__(self) -> str:
        tz_suffix = self.offset
        if self._tz_name:
            tz_suffix = f"{self.offset}[{self._tz_name}]"

        if self._nanosecond:
            # Format with fractional seconds
            base = self._datetime.strftime('%Y-%m-%dT%H:%M:%S')
            frac = f'{self._nanosecond:09d}'.rstrip('0')
            return f'{base}.{frac}{tz_suffix}'
        elif self._datetime.second:
            # Has seconds but no fractional part
            return self._datetime.strftime('%Y-%m-%dT%H:%M:%S') + tz_suffix
        else:
            # No seconds
            return self._datetime.strftime('%Y-%m-%dT%H:%M') + tz_suffix

    def __repr__(self) -> str:
        return f"datetime('{self}')"

    def __eq__(self, other):
        if isinstance(other, CypherDateTime):
            return self._datetime == other._datetime and self._offset == other._offset and self._nanosecond == other._nanosecond
        if isinstance(other, str):
            return str(self) == other
        return False

    def __lt__(self, other):
        if isinstance(other, CypherDateTime):
            # Compare by converting to UTC timestamps
            self_ts = self._datetime.timestamp() - (self._offset.total_seconds() if self._offset else 0)
            other_ts = other._datetime.timestamp() - (other._offset.total_seconds() if other._offset else 0)
            if self_ts == other_ts:
                return self._nanosecond < other._nanosecond
            return self_ts < other_ts
        return NotImplemented

    def __le__(self, other):
        if isinstance(other, CypherDateTime):
            return self == other or self < other
        return NotImplemented

    def __gt__(self, other):
        if isinstance(other, CypherDateTime):
            return not (self <= other)
        return NotImplemented

    def __ge__(self, other):
        if isinstance(other, CypherDateTime):
            return not (self < other)
        return NotImplemented

    def __add__(self, other):
        """Add a duration to this datetime."""
        if isinstance(other, CypherDuration):
            # First add months (calendar-based)
            new_date = _add_months_to_date(self._datetime.date(), other.months)
            # Create new datetime with adjusted date
            new_dt = datetime.combine(new_date, self._datetime.time())
            # Handle nanoseconds with carry
            new_nanos = self._nanosecond + other._nanoseconds
            extra_seconds = new_nanos // 1_000_000_000
            new_nanos = new_nanos % 1_000_000_000
            # Then add days and time components (including extra seconds from nanosecond overflow)
            new_dt = new_dt + timedelta(days=other.days, seconds=other.seconds + extra_seconds)
            return CypherDateTime(new_dt, self._offset, new_nanos, self._tz_name)
        return NotImplemented

    def __radd__(self, other):
        """Support duration + datetime."""
        return self.__add__(other)

    def __sub__(self, other):
        """Subtract a duration from this datetime or subtract two datetimes."""
        if isinstance(other, CypherDuration):
            # First subtract months (calendar-based)
            new_date = _add_months_to_date(self._datetime.date(), -other.months)
            # Create new datetime with adjusted date
            new_dt = datetime.combine(new_date, self._datetime.time())
            # Handle nanoseconds with borrow
            new_nanos = self._nanosecond - other._nanoseconds
            borrow_seconds = 0
            if new_nanos < 0:
                borrow_seconds = (-new_nanos - 1) // 1_000_000_000 + 1
                new_nanos += borrow_seconds * 1_000_000_000
            # Then subtract days and time components (including borrow from nanoseconds)
            new_dt = new_dt - timedelta(days=other.days, seconds=other.seconds + borrow_seconds)
            return CypherDateTime(new_dt, self._offset, new_nanos, self._tz_name)
        if isinstance(other, CypherDateTime):
            # Return duration between two datetimes
            delta = self._datetime - other._datetime
            return CypherDuration(days=delta.days, seconds=delta.total_seconds() % 86400)
        return NotImplemented


@dataclass
class CypherDuration:
    """Cypher duration type."""
    months: int = 0
    days: int = 0
    seconds: int = 0  # Changed from float to int for precision with large values
    _nanoseconds: int = 0  # Internal field for fractional seconds

    def __init__(self, months: Union[int, float] = 0, days: Union[int, float] = 0,
                 seconds: Union[int, float] = 0, nanoseconds: int = 0,
                 hours: Union[int, float] = 0, minutes: Union[int, float] = 0,
                 years: Union[int, float] = 0, weeks: Union[int, float] = 0,
                 milliseconds: Union[int, float] = 0, microseconds: Union[int, float] = 0):
        # Handle fractional months (1 month = 146097/4800 = 30.436875 days per OpenCypher spec)
        total_months = months + years * 12
        int_months = int(total_months)
        frac_months_days = (total_months - int_months) * (146097 / 4800)  # OpenCypher average days per month

        # Handle fractional weeks
        total_weeks = weeks
        total_days = days + total_weeks * 7 + frac_months_days
        int_days = int(total_days)
        frac_days_hours = (total_days - int_days) * 24

        # Handle fractional hours
        total_hours = hours + frac_days_hours
        int_hours = int(total_hours)
        frac_hours_minutes = (total_hours - int_hours) * 60

        # Handle fractional minutes
        total_minutes = minutes + frac_hours_minutes
        int_minutes = int(total_minutes)
        frac_minutes_seconds = (total_minutes - int_minutes) * 60

        # Calculate total seconds - use integer math when possible to preserve precision
        # For large integer seconds values, avoid converting through float
        if isinstance(seconds, int) and frac_minutes_seconds == 0:
            int_seconds = seconds + int_hours * 3600 + int_minutes * 60
            frac_seconds_nanos = 0
        else:
            total_seconds = seconds + int_hours * 3600 + int_minutes * 60 + frac_minutes_seconds
            int_seconds = int(total_seconds)
            frac_seconds_nanos = (total_seconds - int_seconds) * 1_000_000_000

        # Handle milliseconds and microseconds
        total_nanos = nanoseconds + int(milliseconds * 1_000_000) + int(microseconds * 1000) + int(frac_seconds_nanos)

        # Normalize nanoseconds
        extra_seconds, final_nanos = divmod(total_nanos, 1_000_000_000)

        self.months = int_months
        self.days = int_days
        self.seconds = int_seconds + extra_seconds
        self._nanoseconds = int(final_nanos)

    @property
    def totalDays(self) -> float:
        return self.days + self.months * 30  # Approximate

    @property
    def totalSeconds(self) -> float:
        return self.seconds + self._nanoseconds / 1_000_000_000

    @property
    def nanoseconds(self) -> int:
        """Total nanoseconds including seconds converted to nanoseconds."""
        return int(self.seconds * 1_000_000_000) + self._nanoseconds

    def to_timedelta(self) -> timedelta:
        return timedelta(
            days=self.days + self.months * 30,
            seconds=self.seconds,
            microseconds=self._nanoseconds // 1000
        )

    def __str__(self) -> str:
        parts = ['P']
        if self.months:
            # Use truncation towards zero for negative values
            years = int(self.months / 12)  # Truncates towards zero
            months = self.months - years * 12  # Remainder with same sign
            if years:
                parts.append(f'{years}Y')
            if months:
                parts.append(f'{months}M')
        if self.days:
            parts.append(f'{self.days}D')
        if self.seconds or self._nanoseconds:
            parts.append('T')
            # Use integer math to avoid floating point precision issues
            secs = int(self.seconds)
            nanos = self._nanoseconds

            # Handle negative durations
            is_negative = secs < 0 or (secs == 0 and nanos < 0)
            if is_negative:
                # Normalize to positive values for breakdown
                if secs < 0 and nanos > 0:
                    secs = abs(secs) - 1
                    nanos = 1_000_000_000 - nanos
                else:
                    secs = abs(secs)
                    nanos = abs(nanos)

            hours = secs // 3600
            secs = secs % 3600
            minutes = secs // 60
            secs = secs % 60

            if hours:
                parts.append(f'{-hours if is_negative else hours}H')
            if minutes:
                parts.append(f'{-minutes if is_negative else minutes}M')
            if secs or nanos:
                sec_val = -secs if is_negative else secs
                if nanos:
                    # Format fractional seconds without floating point
                    nano_str = str(nanos).zfill(9).rstrip('0')
                    if is_negative and secs == 0:
                        parts.append(f'-{secs}.{nano_str}S')
                    else:
                        parts.append(f'{sec_val}.{nano_str}S')
                else:
                    parts.append(f'{sec_val}S')
        if len(parts) == 1:
            parts.append('T0S')
        return ''.join(parts)

    def __repr__(self) -> str:
        return f"duration('{self}')"

    def __eq__(self, other):
        if isinstance(other, CypherDuration):
            return (self.months == other.months and
                    self.days == other.days and
                    abs(self.seconds - other.seconds) < 1e-9 and
                    self._nanoseconds == other._nanoseconds)
        if isinstance(other, str):
            return str(self) == other
        return False

    def __add__(self, other):
        if isinstance(other, CypherDuration):
            return CypherDuration(
                months=self.months + other.months,
                days=self.days + other.days,
                seconds=self.seconds + other.seconds,
                nanoseconds=self._nanoseconds + other._nanoseconds
            )
        return NotImplemented

    def __sub__(self, other):
        if isinstance(other, CypherDuration):
            return CypherDuration(
                months=self.months - other.months,
                days=self.days - other.days,
                seconds=self.seconds - other.seconds,
                nanoseconds=self._nanoseconds - other._nanoseconds
            )
        return NotImplemented

    def __mul__(self, other):
        if isinstance(other, (int, float)):
            # Don't truncate - let the constructor handle fractional cascading
            return CypherDuration(
                months=self.months * other,
                days=self.days * other,
                seconds=self.seconds * other,
                nanoseconds=int(self._nanoseconds * other)
            )
        return NotImplemented

    def __rmul__(self, other):
        return self.__mul__(other)

    def __truediv__(self, other):
        if isinstance(other, (int, float)):
            return CypherDuration(
                months=self.months / other,
                days=self.days / other,
                seconds=self.seconds / other,
                nanoseconds=int(self._nanoseconds / other)
            )
        return NotImplemented

    @property
    def years(self) -> int:
        """Total years in this duration (months // 12)."""
        return self.months // 12

    @property
    def quarters(self) -> int:
        """Total quarters in this duration."""
        return self.months // 3

    @property
    def weeks(self) -> int:
        """Total weeks in this duration."""
        return self.days // 7

    @property
    def minutes(self) -> int:
        """Total minutes in this duration."""
        return int(self.seconds // 60)

    @property
    def milliseconds(self) -> int:
        """Total milliseconds in this duration."""
        return int(self.seconds * 1000) + (self._nanoseconds // 1_000_000)

    @property
    def microseconds(self) -> int:
        """Total microseconds in this duration."""
        return int(self.seconds * 1_000_000) + (self._nanoseconds // 1000)

    @property
    def monthsOfYear(self) -> int:
        """Months within the current year (0-11)."""
        return self.months % 12

    @property
    def quartersOfYear(self) -> int:
        """Quarters within the current year (0-3)."""
        return (self.months % 12) // 3

    @property
    def monthsOfQuarter(self) -> int:
        """Months within current quarter (0-2)."""
        return (self.months % 12) % 3

    @property
    def daysOfWeek(self) -> int:
        """Days within the week (0-6)."""
        return self.days % 7

    @property
    def hours(self) -> int:
        """Hours component of duration."""
        return int(self.seconds // 3600)

    @property
    def minutesOfHour(self) -> int:
        """Minutes within the hour (0-59)."""
        return int((self.seconds % 3600) // 60)

    @property
    def secondsOfMinute(self) -> int:
        """Seconds within the minute (0-59)."""
        return int(self.seconds % 60)

    @property
    def millisecondsOfSecond(self) -> int:
        """Milliseconds within the second (0-999)."""
        return (self._nanoseconds // 1_000_000) % 1000

    @property
    def microsecondsOfSecond(self) -> int:
        """Microseconds within the second (0-999999)."""
        return (self._nanoseconds // 1000) % 1_000_000

    @property
    def nanosecondsOfSecond(self) -> int:
        """Nanoseconds within the second (0-999999999)."""
        return self._nanoseconds % 1_000_000_000


# === Constructor functions ===

def parse_date_string(s: str) -> CypherDate:
    """Parse date string in various formats.

    Supported formats:
    - '2020-01-15' (standard ISO)
    - '2020-01' (year-month, day defaults to 1)
    - '202001' (compact year-month)
    - '20200115' (compact YYYYMMDD)
    - '2020-015' (year-ordinalDay with dash)
    - '2020015' (compact year-ordinalDay YYYYDDD)
    - '2020-W02-3' (ISO week date)
    - '2020W023' (compact ISO week date)
    - '+999999999-12-31' (extended year)
    """
    s = s.strip("'\"")

    # Handle ISO week date format: 2020-W02-3 or 2020W023
    week_match = re.match(r'^(\d{4})-?W(\d{2})-?(\d)?$', s, re.IGNORECASE)
    if week_match:
        year = int(week_match.group(1))
        week = int(week_match.group(2))
        day_of_week = int(week_match.group(3)) if week_match.group(3) else 1
        jan4 = date(year, 1, 4)
        start_of_week1 = jan4 - timedelta(days=jan4.isoweekday() - 1)
        target_date = start_of_week1 + timedelta(weeks=week - 1, days=day_of_week - 1)
        return CypherDate(target_date)

    # Handle extended year format: +999999999-12-31 or -0001-01-01
    match = re.match(r'^([+-]?\d+)-(\d{2})-(\d{2})$', s)
    if match:
        year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3))
        # Use CypherDate with extended year support
        return CypherDate(year=year, month=month, day=day)

    # Handle year-ordinalDay: 2020-015 (with dash)
    ordinal_match = re.match(r'^(\d{4})-(\d{3})$', s)
    if ordinal_match:
        year = int(ordinal_match.group(1))
        ordinal = int(ordinal_match.group(2))
        return CypherDate(date(year, 1, 1) + timedelta(days=ordinal - 1))

    # Handle compact year-ordinalDay: 2020015 (7 digits)
    if len(s) == 7 and s.isdigit():
        year = int(s[:4])
        ordinal = int(s[4:])
        return CypherDate(date(year, 1, 1) + timedelta(days=ordinal - 1))

    # Handle year-month: 2020-01 (day defaults to 1)
    ym_match = re.match(r'^(\d{4})-(\d{2})$', s)
    if ym_match:
        year = int(ym_match.group(1))
        month = int(ym_match.group(2))
        return CypherDate(date(year, month, 1))

    # Handle compact year-month: 202001 (6 digits, day defaults to 1)
    if len(s) == 6 and s.isdigit():
        year = int(s[:4])
        month = int(s[4:])
        return CypherDate(date(year, month, 1))

    # Handle compact YYYYMMDD: 20200115 (8 digits)
    if len(s) == 8 and s.isdigit():
        year = int(s[:4])
        month = int(s[4:6])
        day = int(s[6:8])
        return CypherDate(date(year, month, day))

    # Handle year only: 2015 (4 digits)
    if len(s) == 4 and s.isdigit():
        year = int(s)
        return CypherDate(date(year, 1, 1))

    # Try standard ISO parsing
    return CypherDate(date.fromisoformat(s))


def cypher_date(arg: Union[str, Dict[str, Any], None] = _UNSET) -> Optional[CypherDate]:
    """Create a date from string or map."""
    if arg is _UNSET:
        return CypherDate(date.today())  # No argument - return current date
    if arg is None:
        return None  # Explicit null - propagate null

    # Handle CypherDate input - copy the date
    if isinstance(arg, CypherDate):
        return arg

    # Handle CypherDateTime or CypherLocalDateTime - extract date component
    if isinstance(arg, (CypherDateTime, CypherLocalDateTime)):
        dt = arg._datetime
        return CypherDate(dt.date())

    if isinstance(arg, str):
        return parse_date_string(arg)

    if isinstance(arg, dict):
        # Handle map construction
        if 'date' in arg:
            # Base date provided
            base = arg['date']
            if isinstance(base, str):
                base = parse_date_string(base)
            if isinstance(base, CypherDate):
                d = base._date
            elif isinstance(base, (CypherLocalDateTime, CypherDateTime)):
                d = base._datetime.date()
            else:
                d = date.today()
        else:
            d = None

        # Get year/month/day with proper defaults from base date
        if d is not None:
            # Base date exists - default to its components unless overridden
            year = arg.get('year', d.year)
            month = arg.get('month', d.month)
            day = arg.get('day', d.day)
        else:
            # No base date - use standard defaults
            year = arg.get('year')
            month = arg.get('month', 1)
            day = arg.get('day', 1)

        week = arg.get('week')
        ordinalDay = arg.get('ordinalDay')
        quarter = arg.get('quarter')
        dayOfQuarter = arg.get('dayOfQuarter')

        # Handle quarter-based dates
        if quarter is not None:
            year_for_quarter = year if year is not None else (d.year if d else date.today().year)
            # First month of the target quarter (Q1=Jan, Q2=Apr, Q3=Jul, Q4=Oct)
            target_quarter_start_month = (quarter - 1) * 3 + 1

            if dayOfQuarter is not None:
                # Explicit dayOfQuarter: count from first day of quarter
                target_date = date(year_for_quarter, target_quarter_start_month, 1) + timedelta(days=dayOfQuarter - 1)
            elif d is not None:
                # No explicit dayOfQuarter but have base date:
                # Preserve month position within quarter and day from base date
                base_quarter_start_month = ((d.month - 1) // 3) * 3 + 1
                month_offset_in_quarter = d.month - base_quarter_start_month  # 0, 1, or 2
                target_month = target_quarter_start_month + month_offset_in_quarter
                target_day = d.day
                # Handle day overflow for shorter months
                import calendar
                max_day = calendar.monthrange(year_for_quarter, target_month)[1]
                target_day = min(target_day, max_day)
                target_date = date(year_for_quarter, target_month, target_day)
            else:
                # No base date and no dayOfQuarter: first day of quarter
                target_date = date(year_for_quarter, target_quarter_start_month, 1)
            return CypherDate(target_date)

        # Handle ordinalDay (day of year)
        if ordinalDay is not None:
            # Use year from arg or base date
            year_for_ordinal = year if year is not None else (d.year if d else date.today().year)
            # Convert ordinal day to date
            target_date = date(year_for_ordinal, 1, 1) + timedelta(days=ordinalDay - 1)
            return CypherDate(target_date)

        # dayOfWeek: if not specified, use base date's day of week if available
        if 'dayOfWeek' in arg:
            dayOfWeek = arg['dayOfWeek']
        elif d is not None and week is not None:
            # Use day of week from base date when week is specified
            dayOfWeek = d.isoweekday()
        else:
            dayOfWeek = 1

        if week is not None:
            # ISO week date
            # If year not explicitly provided but base date exists, use ISO week year from base date
            if 'year' not in arg:
                if d is not None:
                    # Use ISO week year from base date (not calendar year!)
                    year = d.isocalendar()[0]
                elif year is None:
                    year = date.today().year
            elif year is None:
                year = date.today().year
            # Calculate date from ISO week
            jan4 = date(year, 1, 4)
            start_of_week1 = jan4 - timedelta(days=jan4.isoweekday() - 1)
            target_date = start_of_week1 + timedelta(weeks=week - 1, days=dayOfWeek - 1)
            return CypherDate(target_date)

        if year is not None:
            return CypherDate(date(year, month, day))

        if d is not None:
            return CypherDate(d)

        return CypherDate(date.today())

    return None


def parse_time_string(s: str) -> Tuple[time, int]:
    """Parse time string in various formats.

    Supported formats:
    - '12:30:45.123456789' (standard ISO with nanoseconds)
    - '12:30:45' (standard ISO)
    - '12:30' (HH:MM)
    - '123045.123' (compact HHMMSS.fraction)
    - '123045' (compact HHMMSS)
    - '1230' (compact HHMM)
    - '12' (compact HH)

    Returns:
        Tuple of (time object, nanoseconds as int)
    """
    s = s.strip("'\"")
    # Remove timezone for localtime
    if '+' in s:
        s = s.split('+')[0]
    if '-' in s and s.count('-') == 1:
        # Might be negative timezone
        idx = s.rfind('-')
        if idx > 0:  # Not at start
            s = s[:idx]
    if 'Z' in s:
        s = s.replace('Z', '')

    # Check if it contains colons (standard format)
    if ':' in s:
        parts = s.split(':')
        hour = int(parts[0])
        minute = int(parts[1]) if len(parts) > 1 else 0

        second = 0
        nanosecond = 0
        if len(parts) > 2:
            sec_parts = parts[2].split('.')
            second = int(sec_parts[0])
            if len(sec_parts) > 1:
                # Parse full nanoseconds (up to 9 digits)
                frac = sec_parts[1][:9].ljust(9, '0')
                nanosecond = int(frac)

        microsecond = nanosecond // 1000
        return time(hour, minute, second, microsecond), nanosecond

    # Compact format without colons
    # Handle fractional seconds first
    frac_part = ''
    if '.' in s:
        s, frac_part = s.split('.')

    # Now s is digits only: HH, HHMM, or HHMMSS
    if len(s) == 2:  # HH
        hour = int(s)
        minute, second = 0, 0
    elif len(s) == 4:  # HHMM
        hour = int(s[:2])
        minute = int(s[2:4])
        second = 0
    elif len(s) >= 6:  # HHMMSS
        hour = int(s[:2])
        minute = int(s[2:4])
        second = int(s[4:6])
    else:
        raise ValueError(f"Invalid time format: {s}")

    nanosecond = 0
    if frac_part:
        # Parse full nanoseconds (up to 9 digits)
        frac = frac_part[:9].ljust(9, '0')
        nanosecond = int(frac)

    microsecond = nanosecond // 1000
    return time(hour, minute, second, microsecond), nanosecond


def cypher_localtime(arg: Union[str, Dict[str, Any], None] = _UNSET) -> Optional[CypherLocalTime]:
    """Create a localtime from string or map."""
    if arg is _UNSET:
        now = datetime.now()
        return CypherLocalTime(now.time(), now.microsecond * 1000)  # No argument - return current time
    if arg is None:
        return None  # Explicit null - propagate null

    # Handle CypherLocalTime input - return as-is
    if isinstance(arg, CypherLocalTime):
        return arg

    # Handle CypherTime input - extract local time (ignore timezone)
    if isinstance(arg, CypherTime):
        return CypherLocalTime(arg._time, arg._nanosecond)

    # Handle CypherLocalDateTime input - extract time component
    if isinstance(arg, CypherLocalDateTime):
        return CypherLocalTime(arg._datetime.time(), arg._nanosecond)

    # Handle CypherDateTime input - extract time component (ignore timezone)
    if isinstance(arg, CypherDateTime):
        return CypherLocalTime(arg._datetime.time(), arg._nanosecond)

    if isinstance(arg, str):
        t, nanos = parse_time_string(arg)
        return CypherLocalTime(t, nanos)

    if isinstance(arg, dict):
        # Check for base time from 'time' key
        base_hour, base_minute, base_second = 0, 0, 0
        base_nanos = 0
        if 'time' in arg:
            base = arg['time']
            if isinstance(base, CypherLocalTime):
                base_hour = base.hour
                base_minute = base.minute
                base_second = base.second
                base_nanos = base._nanosecond
            elif isinstance(base, CypherTime):
                base_hour = base.hour
                base_minute = base.minute
                base_second = base.second
                base_nanos = base._nanosecond
            elif isinstance(base, (CypherLocalDateTime, CypherDateTime)):
                base_hour = base.hour
                base_minute = base.minute
                base_second = base.second
                base_nanos = base._nanosecond

        hour = arg.get('hour', base_hour)
        minute = arg.get('minute', base_minute)
        second = arg.get('second', base_second)
        millisecond = arg.get('millisecond', 0)
        microsecond = arg.get('microsecond', 0)
        nanosecond = arg.get('nanosecond', base_nanos % 1000 if base_nanos else 0)

        # Calculate total nanoseconds
        total_nanos = nanosecond + microsecond * 1000 + millisecond * 1_000_000
        # If base had nanoseconds and no explicit sub-second values, preserve them
        if base_nanos and 'nanosecond' not in arg and 'microsecond' not in arg and 'millisecond' not in arg:
            total_nanos = base_nanos

        # Python time only stores microseconds, so truncate
        microsec_for_time = (total_nanos // 1000) % 1_000_000

        return CypherLocalTime(time(hour, minute, second, microsec_for_time), total_nanos)

    return None


def cypher_time(arg: Union[str, Dict[str, Any], None] = _UNSET) -> Optional[CypherTime]:
    """Create a time with timezone from string or map."""
    if arg is _UNSET:
        now = datetime.now()
        return CypherTime(now.time(), timedelta(0), now.microsecond * 1000)  # No argument - return current time
    if arg is None:
        return None  # Explicit null - propagate null

    # Handle CypherTime input - return as-is
    if isinstance(arg, CypherTime):
        return arg

    # Handle CypherLocalTime input - add default timezone
    if isinstance(arg, CypherLocalTime):
        return CypherTime(arg._time, timedelta(0), arg._nanosecond)

    # Handle CypherDateTime input - extract time and timezone
    if isinstance(arg, CypherDateTime):
        return CypherTime(arg._datetime.time(), arg._offset, arg._nanosecond)

    # Handle CypherLocalDateTime input - extract time, add default timezone
    if isinstance(arg, CypherLocalDateTime):
        return CypherTime(arg._datetime.time(), timedelta(0), arg._nanosecond)

    if isinstance(arg, str):
        s = arg.strip("'\"")
        offset = timedelta(0)
        if '+' in s:
            time_part, tz_part = s.split('+')
            offset, _ = _parse_timezone('+' + tz_part)
        elif '-' in s and s.count('-') > 0 and s.rfind('-') > 0:
            # Handle negative timezone offset (e.g., '12:30:45-05:00')
            idx = s.rfind('-')
            time_part, tz_part = s[:idx], s[idx:]
            offset, _ = _parse_timezone(tz_part)
        elif s.endswith('Z'):
            time_part = s[:-1]
            offset = timedelta(0)
        else:
            time_part = s
        t, nanos = parse_time_string(time_part)
        return CypherTime(t, offset, nanos)

    if isinstance(arg, dict):
        # Check for base time from 'time' key
        base_hour, base_minute, base_second = 0, 0, 0
        base_nanos = 0
        base_offset = timedelta(0)
        if 'time' in arg:
            base = arg['time']
            if isinstance(base, CypherTime):
                base_hour = base.hour
                base_minute = base.minute
                base_second = base.second
                base_nanos = base._nanosecond
                base_offset = base._offset
            elif isinstance(base, CypherLocalTime):
                base_hour = base.hour
                base_minute = base.minute
                base_second = base.second
                base_nanos = base._nanosecond
            elif isinstance(base, CypherDateTime):
                base_hour = base.hour
                base_minute = base.minute
                base_second = base.second
                base_nanos = base._nanosecond
                base_offset = base._offset
            elif isinstance(base, CypherLocalDateTime):
                base_hour = base.hour
                base_minute = base.minute
                base_second = base.second
                base_nanos = base._nanosecond

        # Timezone handling - may need to convert time
        if 'timezone' in arg:
            timezone = arg['timezone']
            new_offset, _ = _parse_timezone(timezone)

            # If base had a timezone and we're changing it, convert the time
            if base_offset != timedelta(0) and base_offset != new_offset:
                # Convert from base timezone to new timezone
                offset_diff_seconds = int((new_offset - base_offset).total_seconds())
                total_seconds = base_hour * 3600 + base_minute * 60 + base_second + offset_diff_seconds
                # Normalize to 24-hour cycle
                total_seconds = total_seconds % 86400
                if total_seconds < 0:
                    total_seconds += 86400
                base_hour = total_seconds // 3600
                base_minute = (total_seconds % 3600) // 60
                base_second = total_seconds % 60
            offset = new_offset
        else:
            offset = base_offset

        hour = arg.get('hour', base_hour)
        minute = arg.get('minute', base_minute)
        second = arg.get('second', base_second)
        millisecond = arg.get('millisecond', 0)
        microsecond = arg.get('microsecond', 0)
        nanosecond = arg.get('nanosecond', base_nanos % 1000 if base_nanos else 0)

        # Calculate total nanoseconds
        total_nanos = nanosecond + microsecond * 1000 + millisecond * 1_000_000
        # If base had nanoseconds and no explicit sub-second values, preserve them
        if base_nanos and 'nanosecond' not in arg and 'microsecond' not in arg and 'millisecond' not in arg:
            total_nanos = base_nanos

        # Python time only stores microseconds, so truncate
        microsec_for_time = (total_nanos // 1000) % 1_000_000

        return CypherTime(time(hour, minute, second, microsec_for_time), offset, total_nanos)

    return None


@dataclass
class DateTimeComponents:
    """Components of a datetime, supporting extended year range."""
    year: int
    month: int
    day: int
    hour: int
    minute: int
    second: int
    microsecond: int
    nanosecond: int


def parse_datetime_string(s: str) -> Tuple[DateTimeComponents, timedelta, Optional[str], int]:
    """Parse datetime string, return (components, offset, timezone_name, nanoseconds).

    Components are returned as DateTimeComponents to support extended year range.
    """
    s = s.strip("'\"")
    offset = timedelta(0)
    tz_name = None

    # Handle timezone
    # Look for timezone in the string (after T or after date part)
    dt_part = s
    tz_str = None

    # Check for named timezone in brackets [Europe/Stockholm]
    if '[' in s:
        bracket_start = s.index('[')
        bracket_end = s.index(']') if ']' in s else len(s)
        tz_name = s[bracket_start + 1:bracket_end]
        s = s[:bracket_start]  # Remove timezone name part
        dt_part = s  # Update dt_part after removing bracketed timezone

    # Check for + or - timezone offset
    # Timezone offsets look like +HH:MM, +HHMM, or +HH at the end
    # Timezone only applies to datetime strings (those with 'T'), not date-only strings
    if 'T' in s:
        # Only look for timezone after the T
        t_pos = s.index('T')
        search_part = s[t_pos:]

        # Look for timezone pattern at the end: +HH, +HH:MM, +HHMM, -HH, -HH:MM, -HHMM, or Z
        tz_match = re.search(r'([+-])(\d{2})(:\d{2}|\d{2})?$', search_part)
        if tz_match:
            tz_start = t_pos + tz_match.start()
            dt_part = s[:tz_start]
            tz_str = s[tz_start:]
        elif s.endswith('Z'):
            dt_part = s[:-1]
            tz_str = 'Z'

    if tz_str:
        offset, _ = _parse_timezone(tz_str)

    # Parse the datetime part
    if 'T' in dt_part:
        date_part, time_part = dt_part.split('T')
    else:
        date_part = dt_part
        time_part = '00:00:00'

    # Parse date using our extended parser
    d = parse_date_string(date_part)
    year, month, day = d.year, d.month, d.day

    # Parse time using our extended parser
    t, nanos = parse_time_string(time_part)

    components = DateTimeComponents(
        year=year, month=month, day=day,
        hour=t.hour, minute=t.minute, second=t.second,
        microsecond=t.microsecond, nanosecond=nanos
    )
    return components, offset, tz_name, nanos


def cypher_localdatetime(arg: Union[str, Dict[str, Any], None] = _UNSET) -> Optional[CypherLocalDateTime]:
    """Create a localdatetime from string or map."""
    if arg is _UNSET:
        now = datetime.now()
        return CypherLocalDateTime(now, now.microsecond * 1000)  # No argument - return current datetime
    if arg is None:
        return None  # Explicit null - propagate null

    # Handle CypherLocalDateTime input - return as-is
    if isinstance(arg, CypherLocalDateTime):
        return arg

    # Handle CypherDateTime input - drop timezone
    if isinstance(arg, CypherDateTime):
        return CypherLocalDateTime(arg._datetime, arg._nanosecond)

    # Handle CypherDate input - convert to datetime at midnight
    if isinstance(arg, CypherDate):
        return CypherLocalDateTime(datetime.combine(arg._date, time(0, 0, 0)), 0)

    if isinstance(arg, str):
        comp, _, _, nanos = parse_datetime_string(arg)
        return CypherLocalDateTime.from_components(
            comp.year, comp.month, comp.day,
            comp.hour, comp.minute, comp.second, nanos
        )

    if isinstance(arg, dict):
        # Check if there's a base date/datetime - use extended components
        base_year = None
        base_month = None
        base_day = None
        base_hour = 0
        base_minute = 0
        base_second = 0
        base_nanos = 0
        if 'date' in arg:
            base = arg['date']
            if isinstance(base, str):
                base = parse_date_string(base)
            if isinstance(base, CypherDate):
                base_year, base_month, base_day = base.year, base.month, base.day
            elif isinstance(base, CypherLocalDateTime):
                base_year, base_month, base_day = base.year, base.month, base.day
            elif isinstance(base, CypherDateTime):
                base_year, base_month, base_day = base.year, base.month, base.day
        if 'datetime' in arg or 'localdatetime' in arg:
            base = arg.get('datetime') or arg.get('localdatetime')
            if isinstance(base, str):
                comp, _, _, nanos = parse_datetime_string(base)
                base_year, base_month, base_day = comp.year, comp.month, comp.day
                base_hour, base_minute, base_second = comp.hour, comp.minute, comp.second
                base_nanos = nanos
            elif isinstance(base, CypherLocalDateTime):
                base_year, base_month, base_day = base.year, base.month, base.day
                base_hour, base_minute, base_second = base.hour, base.minute, base.second
                base_nanos = base._nanosecond
            elif isinstance(base, CypherDateTime):
                base_year, base_month, base_day = base.year, base.month, base.day
                base_hour, base_minute, base_second = base.hour, base.minute, base.second
                base_nanos = base._nanosecond
        # Handle 'time' key for extracting time components
        if 'time' in arg:
            time_base = arg['time']
            if isinstance(time_base, CypherLocalTime):
                base_hour, base_minute, base_second = time_base.hour, time_base.minute, time_base.second
                base_nanos = time_base._nanosecond
            elif isinstance(time_base, CypherTime):
                base_hour, base_minute, base_second = time_base.hour, time_base.minute, time_base.second
                base_nanos = time_base._nanosecond
            elif isinstance(time_base, CypherLocalDateTime):
                base_hour, base_minute, base_second = time_base.hour, time_base.minute, time_base.second
                base_nanos = time_base._nanosecond
            elif isinstance(time_base, CypherDateTime):
                base_hour, base_minute, base_second = time_base.hour, time_base.minute, time_base.second
                base_nanos = time_base._nanosecond

        # Get date components with proper defaults from base
        if base_year is not None:
            year = arg.get('year', base_year)
            month = arg.get('month', base_month)
            day = arg.get('day', base_day)
        else:
            year = arg.get('year', datetime.now().year)
            month = arg.get('month', 1)
            day = arg.get('day', 1)

        # Get time components with proper defaults from base
        hour = arg.get('hour', base_hour)
        minute = arg.get('minute', base_minute)
        second = arg.get('second', base_second)

        # Calculate total nanoseconds - use base_nanos if no sub-second override specified
        if 'millisecond' not in arg and 'microsecond' not in arg and 'nanosecond' not in arg:
            total_nanos = base_nanos
        else:
            # Extract components from base_nanos if available
            base_ms = (base_nanos // 1_000_000) % 1000 if base_nanos else 0
            base_us = (base_nanos // 1000) % 1000 if base_nanos else 0
            base_ns = base_nanos % 1000 if base_nanos else 0
            millisecond = arg.get('millisecond', base_ms)
            microsecond = arg.get('microsecond', base_us)
            nanosecond = arg.get('nanosecond', base_ns)
            total_nanos = nanosecond + microsecond * 1000 + millisecond * 1_000_000

        # Python datetime only stores microseconds, so truncate
        microsec_for_datetime = (total_nanos // 1000) % 1_000_000

        # Handle quarter-based dates - for extended years use CypherDate
        quarter = arg.get('quarter')
        dayOfQuarter = arg.get('dayOfQuarter')
        if quarter is not None:
            # First day of the quarter (Q1=Jan, Q2=Apr, Q3=Jul, Q4=Oct)
            quarter_start_month = (quarter - 1) * 3 + 1
            day_offset = (dayOfQuarter - 1) if dayOfQuarter else 0
            base_cypher = CypherDate(year=year, month=quarter_start_month, day=1)
            target_cypher = base_cypher + CypherDuration(days=day_offset)
            return CypherLocalDateTime.from_components(
                target_cypher.year, target_cypher.month, target_cypher.day,
                hour, minute, second, total_nanos
            )

        # Handle ordinalDay (day of year)
        ordinalDay = arg.get('ordinalDay')
        if ordinalDay is not None:
            base_cypher = CypherDate(year=year, month=1, day=1)
            target_cypher = base_cypher + CypherDuration(days=ordinalDay - 1)
            return CypherLocalDateTime.from_components(
                target_cypher.year, target_cypher.month, target_cypher.day,
                hour, minute, second, total_nanos
            )

        # Handle week-based dates
        week = arg.get('week')
        if week is not None:
            # dayOfWeek: if not specified, use base date's day of week if available
            if 'dayOfWeek' in arg:
                dayOfWeek = arg['dayOfWeek']
            elif base_year is not None:
                base_cypher = CypherDate(year=base_year, month=base_month, day=base_day)
                dayOfWeek = base_cypher.dayOfWeek
            else:
                dayOfWeek = 1

            # Use ISO week year if base date provided
            if base_year is not None and 'year' not in arg:
                base_cypher = CypherDate(year=base_year, month=base_month, day=base_day)
                year = base_cypher.weekYear
            jan4 = CypherDate(year=year, month=1, day=4)
            start_of_week1 = jan4 + CypherDuration(days=-(jan4.dayOfWeek - 1))
            target_cypher = start_of_week1 + CypherDuration(days=(week - 1) * 7 + dayOfWeek - 1)
            return CypherLocalDateTime.from_components(
                target_cypher.year, target_cypher.month, target_cypher.day,
                hour, minute, second, total_nanos
            )

        return CypherLocalDateTime.from_components(year, month, day, hour, minute, second, total_nanos)

    return None


def cypher_datetime(arg: Union[str, Dict[str, Any], None] = _UNSET) -> Optional[CypherDateTime]:
    """Create a datetime with timezone from string or map."""
    if arg is _UNSET:
        now = datetime.now()
        return CypherDateTime(now, timedelta(0), now.microsecond * 1000)  # No argument - return current datetime
    if arg is None:
        return None  # Explicit null - propagate null

    # Handle CypherDateTime input - return as-is
    if isinstance(arg, CypherDateTime):
        return arg

    # Handle CypherLocalDateTime input - add default timezone
    if isinstance(arg, CypherLocalDateTime):
        # CypherLocalDateTime may have extended year - create datetime only if in Python range
        if 1 <= arg.year <= 9999:
            dt = datetime(arg.year, arg.month, arg.day, arg.hour, arg.minute, arg.second)
            return CypherDateTime(dt, timedelta(0), arg._nanosecond)
        else:
            raise ValueError(f"CypherDateTime does not support year {arg.year}")

    # Handle CypherDate input - convert to datetime at midnight with UTC
    if isinstance(arg, CypherDate):
        if 1 <= arg.year <= 9999:
            dt = datetime(arg.year, arg.month, arg.day, 0, 0, 0)
            return CypherDateTime(dt, timedelta(0), 0)
        else:
            raise ValueError(f"CypherDateTime does not support year {arg.year}")

    if isinstance(arg, str):
        comp, offset, tz_name, nanos = parse_datetime_string(arg)
        if 1 <= comp.year <= 9999:
            dt = datetime(comp.year, comp.month, comp.day, comp.hour, comp.minute, comp.second, comp.microsecond)
            return CypherDateTime(dt, offset, nanos, tz_name)
        else:
            raise ValueError(f"CypherDateTime does not support year {comp.year}")

    if isinstance(arg, dict):
        # Check if there's a base date/datetime
        base_dt = None
        base_time = None
        base_nanos = 0
        base_offset = None
        base_tz_name = None
        if 'date' in arg:
            base = arg['date']
            if isinstance(base, str):
                base = parse_date_string(base)
            if isinstance(base, CypherDate):
                base_dt = base._date
            elif isinstance(base, (CypherLocalDateTime, CypherDateTime)):
                base_dt = base._datetime.date()
        if 'datetime' in arg or 'localdatetime' in arg:
            base = arg.get('datetime') or arg.get('localdatetime')
            if isinstance(base, str):
                dt, _, _, nanos = parse_datetime_string(base)
                base_dt = dt.date()
                base_time = dt.time()
                base_nanos = nanos
            elif isinstance(base, CypherDateTime):
                base_dt = base._datetime.date()
                base_time = base._datetime.time()
                base_nanos = base._nanosecond
                base_offset = base._offset
                base_tz_name = base._tz_name
                # Handle timezone conversion when changing from datetime's timezone to a new one
                if 'timezone' in arg:
                    old_offset = base._offset or timedelta(0)
                    new_offset, new_tz_name = _parse_timezone(arg['timezone'])
                    # For named timezones, compute actual offset using a reference date
                    if new_tz_name and ZoneInfo is not None:
                        try:
                            ref_year = arg.get('year', base._datetime.year)
                            ref_month = arg.get('month', base._datetime.month)
                            ref_day = arg.get('day', base._datetime.day)
                            ref_dt = datetime(ref_year, ref_month, ref_day, base_time.hour, base_time.minute, base_time.second)
                            tz_info = ZoneInfo(new_tz_name)
                            ref_dt_aware = ref_dt.replace(tzinfo=tz_info)
                            new_offset = ref_dt_aware.utcoffset() or timedelta(0)
                        except Exception:
                            pass
                    if old_offset != new_offset:
                        # Convert time from old timezone to new timezone
                        offset_diff_seconds = int((new_offset - old_offset).total_seconds())
                        total_seconds = base_time.hour * 3600 + base_time.minute * 60 + base_time.second + offset_diff_seconds
                        # Normalize to 24-hour cycle and handle day overflow
                        day_overflow = 0
                        if total_seconds >= 86400:
                            day_overflow = total_seconds // 86400
                            total_seconds = total_seconds % 86400
                        elif total_seconds < 0:
                            day_overflow = -((abs(total_seconds) + 86399) // 86400)
                            total_seconds = total_seconds % 86400
                        # Create a new time object with converted values
                        conv_hour = total_seconds // 3600
                        conv_minute = (total_seconds % 3600) // 60
                        conv_second = total_seconds % 60
                        base_time = time(conv_hour, conv_minute, conv_second, base_time.microsecond)
                        # Adjust base_dt for day overflow
                        if day_overflow != 0:
                            base_dt = base_dt + timedelta(days=day_overflow)
            elif isinstance(base, CypherLocalDateTime):
                base_dt = base._datetime.date()
                base_time = base._datetime.time()
                base_nanos = base._nanosecond

        # Handle 'time' key to extract time from temporal values
        if 'time' in arg:
            time_base = arg['time']
            if isinstance(time_base, CypherLocalTime):
                base_time = time_base._time
                base_nanos = time_base._nanosecond
            elif isinstance(time_base, CypherTime):
                base_time = time_base._time
                base_nanos = time_base._nanosecond
                # Handle timezone conversion when changing from time's timezone to a new one
                if 'timezone' in arg:
                    old_offset = time_base._offset or timedelta(0)
                    new_offset, new_tz_name = _parse_timezone(arg['timezone'])
                    # For named timezones, compute actual offset using a reference date
                    if new_tz_name and ZoneInfo is not None:
                        try:
                            ref_year = arg.get('year', 2000)
                            ref_month = arg.get('month', 1)
                            ref_day = arg.get('day', 1)
                            ref_dt = datetime(ref_year, ref_month, ref_day, base_time.hour, base_time.minute, base_time.second)
                            tz_info = ZoneInfo(new_tz_name)
                            ref_dt_aware = ref_dt.replace(tzinfo=tz_info)
                            new_offset = ref_dt_aware.utcoffset() or timedelta(0)
                        except Exception:
                            pass
                    if old_offset != new_offset:
                        # Convert time from old timezone to new timezone
                        offset_diff_seconds = int((new_offset - old_offset).total_seconds())
                        total_seconds = base_time.hour * 3600 + base_time.minute * 60 + base_time.second + offset_diff_seconds
                        # Normalize to 24-hour cycle
                        total_seconds = total_seconds % 86400
                        if total_seconds < 0:
                            total_seconds += 86400
                        # Create a new time object with converted values (overrides will apply later)
                        conv_hour = total_seconds // 3600
                        conv_minute = (total_seconds % 3600) // 60
                        conv_second = total_seconds % 60
                        base_time = time(conv_hour, conv_minute, conv_second, base_time.microsecond)
                else:
                    base_offset = time_base._offset
            elif isinstance(time_base, CypherLocalDateTime):
                base_time = time_base._datetime.time()
                base_nanos = time_base._nanosecond
            elif isinstance(time_base, CypherDateTime):
                base_time = time_base._datetime.time()
                base_nanos = time_base._nanosecond
                # Handle timezone conversion when changing from datetime's timezone to a new one
                if 'timezone' in arg:
                    old_offset = time_base._offset or timedelta(0)
                    old_tz_name = time_base._tz_name
                    new_offset, new_tz_name = _parse_timezone(arg['timezone'])
                    # Compute the target reference date (using base_dt from date: key if present)
                    if base_dt is not None:
                        ref_year = arg.get('year', base_dt.year)
                        ref_month = arg.get('month', base_dt.month)
                        ref_day = arg.get('day', base_dt.day)
                    else:
                        ref_year = arg.get('year', time_base._datetime.year)
                        ref_month = arg.get('month', time_base._datetime.month)
                        ref_day = arg.get('day', time_base._datetime.day)
                    # Re-evaluate OLD named timezone offset at the target date
                    if old_tz_name and ZoneInfo is not None:
                        try:
                            ref_dt = datetime(ref_year, ref_month, ref_day, base_time.hour, base_time.minute, base_time.second)
                            tz_info = ZoneInfo(old_tz_name)
                            ref_dt_aware = ref_dt.replace(tzinfo=tz_info)
                            old_offset = ref_dt_aware.utcoffset() or timedelta(0)
                        except Exception:
                            pass
                    # For named NEW timezones, compute actual offset using the reference date
                    if new_tz_name and ZoneInfo is not None:
                        try:
                            ref_dt = datetime(ref_year, ref_month, ref_day, base_time.hour, base_time.minute, base_time.second)
                            tz_info = ZoneInfo(new_tz_name)
                            ref_dt_aware = ref_dt.replace(tzinfo=tz_info)
                            new_offset = ref_dt_aware.utcoffset() or timedelta(0)
                        except Exception:
                            pass
                    if old_offset != new_offset:
                        # Convert time from old timezone to new timezone
                        offset_diff_seconds = int((new_offset - old_offset).total_seconds())
                        total_seconds = base_time.hour * 3600 + base_time.minute * 60 + base_time.second + offset_diff_seconds
                        # Normalize to 24-hour cycle
                        total_seconds = total_seconds % 86400
                        if total_seconds < 0:
                            total_seconds += 86400
                        # Create a new time object with converted values (overrides will apply later)
                        conv_hour = total_seconds // 3600
                        conv_minute = (total_seconds % 3600) // 60
                        conv_second = total_seconds % 60
                        base_time = time(conv_hour, conv_minute, conv_second, base_time.microsecond)
                else:
                    base_offset = time_base._offset
                    base_tz_name = time_base._tz_name

        # Get date components with proper defaults from base
        if base_dt is not None:
            year = arg.get('year', base_dt.year)
            month = arg.get('month', base_dt.month)
            day = arg.get('day', base_dt.day)
        else:
            year = arg.get('year', datetime.now().year)
            month = arg.get('month', 1)
            day = arg.get('day', 1)

        # Get time components with proper defaults from base
        if base_time is not None:
            hour = arg.get('hour', base_time.hour)
            minute = arg.get('minute', base_time.minute)
            second = arg.get('second', base_time.second)
        else:
            hour = arg.get('hour', 0)
            minute = arg.get('minute', 0)
            second = arg.get('second', 0)

        # Calculate total nanoseconds - use base_nanos if no sub-second override specified
        if 'millisecond' not in arg and 'microsecond' not in arg and 'nanosecond' not in arg:
            total_nanos = base_nanos
        else:
            # Extract components from base_nanos if available
            base_ms = (base_nanos // 1_000_000) % 1000 if base_nanos else 0
            base_us = (base_nanos // 1000) % 1000 if base_nanos else 0
            base_ns = base_nanos % 1000 if base_nanos else 0
            millisecond = arg.get('millisecond', base_ms)
            microsecond = arg.get('microsecond', base_us)
            nanosecond = arg.get('nanosecond', base_ns)
            total_nanos = nanosecond + microsecond * 1000 + millisecond * 1_000_000

        # Python datetime only stores microseconds, so truncate
        microsec_for_datetime = (total_nanos // 1000) % 1_000_000

        # Handle quarter-based dates
        quarter = arg.get('quarter')
        dayOfQuarter = arg.get('dayOfQuarter')
        if quarter is not None:
            # First day of the quarter (Q1=Jan, Q2=Apr, Q3=Jul, Q4=Oct)
            quarter_start_month = (quarter - 1) * 3 + 1
            day_offset = (dayOfQuarter - 1) if dayOfQuarter else 0
            target_date = date(year, quarter_start_month, 1) + timedelta(days=day_offset)

            # Timezone - use base values if available and no explicit timezone
            if 'timezone' in arg:
                offset, tz_name = _parse_timezone(arg['timezone'])
            elif base_offset is not None:
                offset = base_offset
                tz_name = base_tz_name
            else:
                offset, tz_name = _parse_timezone('Z')

            return CypherDateTime(datetime(
                target_date.year, target_date.month, target_date.day,
                hour, minute, second, microsec_for_datetime
            ), offset, total_nanos, tz_name)

        # Handle ordinalDay (day of year)
        ordinalDay = arg.get('ordinalDay')
        if ordinalDay is not None:
            # Convert ordinal day to date, get timezone
            target_date = date(year, 1, 1) + timedelta(days=ordinalDay - 1)

            # Timezone - use base values if available and no explicit timezone
            if 'timezone' in arg:
                offset, tz_name = _parse_timezone(arg['timezone'])
            elif base_offset is not None:
                offset = base_offset
                tz_name = base_tz_name
            else:
                offset, tz_name = _parse_timezone('Z')

            return CypherDateTime(datetime(
                target_date.year, target_date.month, target_date.day,
                hour, minute, second, microsec_for_datetime
            ), offset, total_nanos, tz_name)

        # Handle week-based dates
        week = arg.get('week')
        if week is not None:
            # dayOfWeek: if not specified, use base date's day of week if available
            if 'dayOfWeek' in arg:
                dayOfWeek = arg['dayOfWeek']
            elif base_dt is not None:
                dayOfWeek = base_dt.isoweekday()
            else:
                dayOfWeek = 1

            # Use ISO week year if base date provided
            if base_dt is not None and 'year' not in arg:
                year = base_dt.isocalendar()[0]

            jan4 = date(year, 1, 4)
            start_of_week1 = jan4 - timedelta(days=jan4.isoweekday() - 1)
            target_date = start_of_week1 + timedelta(weeks=week - 1, days=dayOfWeek - 1)
            year, month, day = target_date.year, target_date.month, target_date.day

        # Timezone - use base values if available and no explicit timezone
        if 'timezone' in arg:
            offset, tz_name = _parse_timezone(arg['timezone'])
        elif base_offset is not None:
            offset = base_offset
            tz_name = base_tz_name
        else:
            offset, tz_name = _parse_timezone('Z')

        return CypherDateTime(datetime(year, month, day, hour, minute, second, microsec_for_datetime), offset, total_nanos, tz_name)

    return None


def cypher_datetime_fromepoch(seconds: int, nanoseconds: int = 0) -> CypherDateTime:
    """Create datetime from epoch seconds and nanoseconds."""
    # Use fromtimestamp with UTC timezone (avoids deprecated utcfromtimestamp)
    dt = datetime.fromtimestamp(seconds, tz=dt_timezone.utc).replace(tzinfo=None)
    # Add microseconds from nanoseconds
    microseconds = nanoseconds // 1000
    dt = dt.replace(microsecond=microseconds % 1_000_000)
    return CypherDateTime(dt, timedelta(0), nanoseconds)


def cypher_datetime_fromepochmillis(millis: int) -> CypherDateTime:
    """Create datetime from epoch milliseconds."""
    seconds = millis // 1000
    millisecond_remainder = millis % 1000
    nanoseconds = millisecond_remainder * 1_000_000
    dt = datetime.fromtimestamp(seconds, tz=dt_timezone.utc).replace(tzinfo=None)
    dt = dt.replace(microsecond=millisecond_remainder * 1000)
    return CypherDateTime(dt, timedelta(0), nanoseconds)


def cypher_duration(arg: Union[str, Dict[str, Any], None] = _UNSET) -> Optional[CypherDuration]:
    """Create a duration from string or map."""
    if arg is _UNSET:
        return CypherDuration()  # No argument - return default
    if arg is None:
        return None  # Explicit null - propagate null

    if isinstance(arg, str):
        s = arg.strip("'\"")
        # Parse ISO 8601 duration: P1Y2M3DT4H5M6S or P2012-02-02T14:37:21.545
        months = days = 0
        seconds = 0.0
        nanoseconds = 0

        if s.startswith('P'):
            s = s[1:]

        # Split into date and time parts
        if 'T' in s:
            date_part, time_part = s.split('T')
        else:
            date_part = s
            time_part = ''

        # Check if date_part is in datetime format (contains hyphens like 2012-02-02)
        # but NOT if it contains YMWD letters (standard duration format like 12Y5M-14D)
        has_duration_markers = any(c in date_part for c in 'YMWD')
        if '-' in date_part and date_part[0].isdigit() and not has_duration_markers:
            # Parse datetime-like format: YYYY-MM-DD
            parts = date_part.split('-')
            if len(parts) == 3:
                years = int(parts[0])
                months = years * 12 + int(parts[1])
                days = int(parts[2])
        else:
            # Parse standard format with YMWD markers (allow decimal and negative values)
            for match in re.finditer(r'(-?[\d.]+)([YMWD])', date_part):
                value = float(match.group(1))
                unit = match.group(2)
                if unit == 'Y':
                    months += value * 12
                elif unit == 'M':
                    months += value
                elif unit == 'W':
                    days += value * 7
                elif unit == 'D':
                    days += value

        # Check if time_part is in datetime format (contains colons like 14:37:21.545)
        if ':' in time_part and time_part[0].isdigit():
            # Parse datetime-like format: HH:MM:SS.sss
            time_parts = time_part.split(':')
            if len(time_parts) >= 1:
                seconds += int(time_parts[0]) * 3600
            if len(time_parts) >= 2:
                seconds += int(time_parts[1]) * 60
            if len(time_parts) >= 3:
                # Parse seconds and fractional part separately to avoid float precision issues
                sec_str = time_parts[2]
                if '.' in sec_str:
                    whole_sec, frac_sec = sec_str.split('.')
                    seconds += int(whole_sec)
                    # Pad/truncate fractional part to 9 digits for nanoseconds
                    frac_sec = frac_sec.ljust(9, '0')[:9]
                    nanoseconds = int(frac_sec)
                else:
                    seconds += int(sec_str)
        else:
            # Parse standard format with HMS markers (allow negative values)
            for match in re.finditer(r'(-?[\d.]+)([HMS])', time_part):
                value_str = match.group(1)
                unit = match.group(2)
                if unit == 'H':
                    seconds += float(value_str) * 3600
                elif unit == 'M':
                    seconds += float(value_str) * 60
                elif unit == 'S':
                    # Handle fractional seconds precisely
                    is_negative = value_str.startswith('-')
                    if is_negative:
                        value_str = value_str[1:]
                    if '.' in value_str:
                        whole_sec, frac_sec = value_str.split('.')
                        seconds += (int(whole_sec) if whole_sec else 0) * (-1 if is_negative else 1)
                        frac_sec = frac_sec.ljust(9, '0')[:9]
                        nanoseconds = int(frac_sec) * (-1 if is_negative else 1)
                    else:
                        seconds += int(value_str) * (-1 if is_negative else 1)

        return CypherDuration(months=months, days=days, seconds=seconds, nanoseconds=nanoseconds)

    if isinstance(arg, dict):
        return CypherDuration(
            years=arg.get('years', 0),
            months=arg.get('months', 0),
            weeks=arg.get('weeks', 0),
            days=arg.get('days', 0),
            hours=arg.get('hours', 0),
            minutes=arg.get('minutes', 0),
            seconds=arg.get('seconds', 0),
            milliseconds=arg.get('milliseconds', 0),
            microseconds=arg.get('microseconds', 0),
            nanoseconds=arg.get('nanoseconds', 0)
        )

    return None


def duration_between(start, end) -> Optional[CypherDuration]:
    """Calculate duration between two temporal values.

    Returns duration with months, days, and seconds components.
    For dates: returns years, months, and days
    For times: returns seconds and nanoseconds
    For datetimes: returns years, months, days, seconds, and nanoseconds
    """
    # Null propagation
    if start is None or end is None:
        return None

    # Date to date: calculate in years, months, days
    if isinstance(start, CypherDate) and isinstance(end, CypherDate):
        # Use CypherDate properties directly for extended range support
        # Calculate year and month difference
        years = end.year - start.year
        months = end.month - start.month
        days = end.day - start.day

        # Normalize: if days negative, borrow from months
        if days < 0:
            months -= 1
            # Get days in previous month of end date
            prev_month = end.month - 1 if end.month > 1 else 12
            prev_year = end.year if end.month > 1 else end.year - 1
            days_in_prev_month = _days_in_month_extended(prev_year, prev_month)
            days += days_in_prev_month

        # Normalize: if months negative, borrow from years
        if months < 0:
            years -= 1
            months += 12

        total_months = years * 12 + months
        return CypherDuration(months=total_months, days=days)

    # LocalDateTime to LocalDateTime
    if isinstance(start, CypherLocalDateTime) and isinstance(end, CypherLocalDateTime):
        d1, d2 = start._datetime, end._datetime

        # Check if we span different years or months - if so, use calendar format
        if d1.year != d2.year or d1.month != d2.month:
            # Calculate calendar difference
            years = d2.year - d1.year
            months = d2.month - d1.month
            days = d2.day - d1.day

            # Time difference in seconds
            time_diff = (
                (d2.hour - d1.hour) * 3600 +
                (d2.minute - d1.minute) * 60 +
                (d2.second - d1.second)
            )
            nano_diff = end._nanosecond - start._nanosecond

            # Normalize nanoseconds
            if nano_diff < 0:
                time_diff -= 1
                nano_diff += 1_000_000_000

            # Normalize time into days
            if time_diff < 0:
                days -= 1
                time_diff += 86400
            elif time_diff >= 86400:
                days += time_diff // 86400
                time_diff = time_diff % 86400

            # Normalize days into months
            if days < 0:
                months -= 1
                import calendar
                prev_month = d2.month - 1 if d2.month > 1 else 12
                prev_year = d2.year if d2.month > 1 else d2.year - 1
                days += calendar.monthrange(prev_year, prev_month)[1]

            # Normalize months into years
            if months < 0:
                years -= 1
                months += 12

            total_months = years * 12 + months
            return CypherDuration(months=total_months, days=days, seconds=time_diff, nanoseconds=nano_diff)
        else:
            # Same year and month - use pure seconds format
            d1_no_micro = d1.replace(microsecond=0)
            d2_no_micro = d2.replace(microsecond=0)
            delta = d2_no_micro - d1_no_micro
            total_seconds = int(delta.total_seconds())
            nano_diff = end._nanosecond - start._nanosecond
            # Normalize nanoseconds with correct sign handling
            if nano_diff < 0 and total_seconds > 0:
                total_seconds -= 1
                nano_diff += 1_000_000_000
            elif nano_diff > 0 and total_seconds < 0:
                total_seconds += 1
                nano_diff -= 1_000_000_000
            return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)

    # DateTime to DateTime - accounting for timezone
    if isinstance(start, CypherDateTime) and isinstance(end, CypherDateTime):
        d1, d2 = start._datetime, end._datetime
        # Account for timezone offsets - convert to UTC
        offset1 = start._offset or timedelta(0)
        offset2 = end._offset or timedelta(0)
        utc1 = d1 - offset1
        utc2 = d2 - offset2

        # If same year and month in UTC, use seconds format
        if utc1.year == utc2.year and utc1.month == utc2.month:
            utc1_no_micro = utc1.replace(microsecond=0)
            utc2_no_micro = utc2.replace(microsecond=0)
            delta = utc2_no_micro - utc1_no_micro
            total_seconds = int(delta.total_seconds())
            nano_diff = end._nanosecond - start._nanosecond
            # Normalize nanoseconds with correct sign handling
            if nano_diff < 0 and total_seconds > 0:
                total_seconds -= 1
                nano_diff += 1_000_000_000
            elif nano_diff > 0 and total_seconds < 0:
                total_seconds += 1
                nano_diff -= 1_000_000_000
            return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)

        # Different year or month - use calendar format
        # Calculate calendar difference based on UTC times
        years = utc2.year - utc1.year
        months = utc2.month - utc1.month
        days = utc2.day - utc1.day

        # Time difference in seconds
        time_diff = (
            (utc2.hour - utc1.hour) * 3600 +
            (utc2.minute - utc1.minute) * 60 +
            (utc2.second - utc1.second)
        )
        nano_diff = end._nanosecond - start._nanosecond

        # Normalize nanoseconds
        if nano_diff < 0:
            time_diff -= 1
            nano_diff += 1_000_000_000

        # Normalize time into days
        if time_diff < 0:
            days -= 1
            time_diff += 86400
        elif time_diff >= 86400:
            days += time_diff // 86400
            time_diff = time_diff % 86400

        # Normalize days into months
        if days < 0:
            months -= 1
            import calendar
            prev_month = utc2.month - 1 if utc2.month > 1 else 12
            prev_year = utc2.year if utc2.month > 1 else utc2.year - 1
            days += calendar.monthrange(prev_year, prev_month)[1]

        # Normalize months into years
        if months < 0:
            years -= 1
            months += 12

        total_months = years * 12 + months
        return CypherDuration(months=total_months, days=days, seconds=time_diff, nanoseconds=nano_diff)

    # Time to Time (localtime or time)
    if isinstance(start, (CypherLocalTime, CypherTime)) and isinstance(end, (CypherLocalTime, CypherTime)):
        # Get base time components in seconds
        start_seconds = start.hour * 3600 + start.minute * 60 + start.second
        end_seconds = end.hour * 3600 + end.minute * 60 + end.second

        # If both are CypherTime (have timezone), normalize to UTC for comparison
        if isinstance(start, CypherTime) and isinstance(end, CypherTime):
            start_offset = start._offset.total_seconds() if start._offset else 0
            end_offset = end._offset.total_seconds() if end._offset else 0
            start_seconds -= int(start_offset)
            end_seconds -= int(end_offset)

        time_diff_seconds = end_seconds - start_seconds
        nano_diff = end._nanosecond - start._nanosecond

        # Normalize nanoseconds
        if nano_diff < 0:
            time_diff_seconds -= 1
            nano_diff += 1_000_000_000

        return CypherDuration(seconds=time_diff_seconds, nanoseconds=nano_diff)

    # Mixed type: Date to LocalDateTime/DateTime - add date difference + time
    if isinstance(start, CypherDate) and isinstance(end, (CypherLocalDateTime, CypherDateTime)):
        # Calculate full difference using date at midnight
        d1 = start._date
        d2 = end._datetime

        years = d2.year - d1.year
        months = d2.month - d1.month
        days = d2.day - d1.day
        time_diff_seconds = d2.hour * 3600 + d2.minute * 60 + d2.second
        nano_diff = end._nanosecond

        # Normalize
        if days < 0:
            months -= 1
            import calendar
            prev_month = d2.month - 1 if d2.month > 1 else 12
            prev_year = d2.year if d2.month > 1 else d2.year - 1
            days += calendar.monthrange(prev_year, prev_month)[1]
        if months < 0:
            years -= 1
            months += 12

        total_months = years * 12 + months
        return CypherDuration(months=total_months, days=days, seconds=time_diff_seconds, nanoseconds=nano_diff)

    # Mixed type: Date to LocalTime/Time - just the time portion
    if isinstance(start, CypherDate) and isinstance(end, (CypherLocalTime, CypherTime)):
        time_diff_seconds = end.hour * 3600 + end.minute * 60 + end.second
        return CypherDuration(seconds=time_diff_seconds, nanoseconds=end._nanosecond)

    # Mixed type: LocalTime/Time to Date - negative of the time
    if isinstance(start, (CypherLocalTime, CypherTime)) and isinstance(end, CypherDate):
        time_diff_seconds = -(start.hour * 3600 + start.minute * 60 + start.second)
        nano_diff = -start._nanosecond
        # Normalize nanoseconds
        if nano_diff < 0:
            time_diff_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=time_diff_seconds, nanoseconds=nano_diff)

    # Mixed type: LocalTime/Time to LocalDateTime/DateTime
    if isinstance(start, (CypherLocalTime, CypherTime)) and isinstance(end, (CypherLocalDateTime, CypherDateTime)):
        # Get start time in seconds
        start_seconds = start.hour * 3600 + start.minute * 60 + start.second
        # Get end time in seconds
        end_seconds = end.hour * 3600 + end.minute * 60 + end.second

        # Only apply timezone conversion if BOTH have timezone info (CypherTime + CypherDateTime)
        if isinstance(start, CypherTime) and isinstance(end, CypherDateTime):
            # Both have timezone - normalize to UTC for comparison
            start_offset = start._offset.total_seconds() if start._offset else 0
            end_offset = end._offset.total_seconds() if end._offset else 0
            start_seconds -= int(start_offset)
            end_seconds -= int(end_offset)

        time_diff_seconds = end_seconds - start_seconds
        nano_diff = end._nanosecond - start._nanosecond
        if nano_diff < 0:
            time_diff_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=time_diff_seconds, nanoseconds=nano_diff)

    # Mixed type: LocalDateTime/DateTime to Date
    if isinstance(start, CypherLocalDateTime) and isinstance(end, CypherDate):
        # For LocalDateTime to Date, express result in days + time (both can be negative)
        d1 = start._datetime
        d2 = end._date

        # Days between the date portions
        days = (d2 - d1.date()).days
        # Time portion is negated (going backwards in time within the day)
        time_diff_seconds = -(d1.hour * 3600 + d1.minute * 60 + d1.second)
        nano_diff = -start._nanosecond
        if nano_diff != 0:
            if nano_diff < 0:
                time_diff_seconds -= 1
                nano_diff += 1_000_000_000
            elif nano_diff > 0 and time_diff_seconds < 0:
                # Keep nanoseconds and seconds with same sign
                time_diff_seconds += 1
                nano_diff -= 1_000_000_000

        return CypherDuration(days=days, seconds=time_diff_seconds, nanoseconds=nano_diff)

    if isinstance(start, CypherDateTime) and isinstance(end, CypherDate):
        # For DateTime to Date, use calendar months
        # The date is interpreted in the same timezone as the datetime (local time)
        d1 = start._datetime  # Local time of the datetime
        d2 = end._date

        # Calculate calendar difference from start date to end date
        years = d2.year - d1.year
        months = d2.month - d1.month
        days = d2.day - d1.day

        # The time component: remaining time from local datetime to end of day (midnight)
        # This is 24:00:00 - time = how much time to add to get to midnight
        time_from_local = d1.hour * 3600 + d1.minute * 60 + d1.second
        time_to_midnight = 86400 - time_from_local
        nano_from_local = start._nanosecond
        nano_to_midnight = 1_000_000_000 - nano_from_local if nano_from_local > 0 else 0

        if nano_from_local > 0:
            time_to_midnight -= 1

        # If we have positive time past midnight, we need to adjust the day count
        if time_from_local > 0 or nano_from_local > 0:
            # We're partway through the start day, so we need to adjust
            days -= 1

        # Normalize days
        if days < 0:
            months -= 1
            import calendar
            prev_month = d2.month - 1 if d2.month > 1 else 12
            prev_year = d2.year if d2.month > 1 else d2.year - 1
            days += calendar.monthrange(prev_year, prev_month)[1]

        # Normalize months
        if months < 0:
            years -= 1
            months += 12

        total_months = years * 12 + months

        return CypherDuration(months=total_months, days=days, seconds=time_to_midnight, nanoseconds=nano_to_midnight)

    # Mixed type: LocalDateTime/DateTime to LocalTime/Time
    if isinstance(start, (CypherLocalDateTime, CypherDateTime)) and isinstance(end, (CypherLocalTime, CypherTime)):
        start_seconds = start.hour * 3600 + start.minute * 60 + start.second
        end_seconds = end.hour * 3600 + end.minute * 60 + end.second

        # If both have timezone info (CypherDateTime + CypherTime), normalize to UTC
        if isinstance(start, CypherDateTime) and isinstance(end, CypherTime):
            start_offset = start._offset.total_seconds() if start._offset else 0
            end_offset = end._offset.total_seconds() if end._offset else 0
            start_seconds -= int(start_offset)
            end_seconds -= int(end_offset)

        time_diff_seconds = end_seconds - start_seconds
        nano_diff = end._nanosecond - start._nanosecond
        if nano_diff < 0:
            time_diff_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=time_diff_seconds, nanoseconds=nano_diff)

    # Mixed type: LocalDateTime to DateTime - compare using local times (ignore timezone on DateTime)
    if isinstance(start, CypherLocalDateTime) and isinstance(end, CypherDateTime):
        d1, d2 = start._datetime, end._datetime  # Use local time of DateTime

        # Calculate calendar difference
        years = d2.year - d1.year
        months = d2.month - d1.month
        days = d2.day - d1.day

        # Time difference in seconds
        time_diff = (
            (d2.hour - d1.hour) * 3600 +
            (d2.minute - d1.minute) * 60 +
            (d2.second - d1.second)
        )
        nano_diff = end._nanosecond - start._nanosecond

        # Normalize nanoseconds
        if nano_diff < 0:
            time_diff -= 1
            nano_diff += 1_000_000_000

        # Normalize time into days
        if time_diff < 0:
            days -= 1
            time_diff += 86400
        elif time_diff >= 86400:
            days += time_diff // 86400
            time_diff = time_diff % 86400

        # Normalize days into months
        if days < 0:
            months -= 1
            import calendar
            prev_month = d2.month - 1 if d2.month > 1 else 12
            prev_year = d2.year if d2.month > 1 else d2.year - 1
            days += calendar.monthrange(prev_year, prev_month)[1]

        # Normalize months into years
        if months < 0:
            years -= 1
            months += 12

        total_months = years * 12 + months
        return CypherDuration(months=total_months, days=days, seconds=time_diff, nanoseconds=nano_diff)

    # Mixed type: DateTime to LocalDateTime - compare using local times (ignore timezone on DateTime)
    if isinstance(start, CypherDateTime) and isinstance(end, CypherLocalDateTime):
        d1, d2 = start._datetime, end._datetime  # Use local time of DateTime

        # Calculate calendar difference
        years = d2.year - d1.year
        months = d2.month - d1.month
        days = d2.day - d1.day

        # Time difference in seconds
        time_diff = (
            (d2.hour - d1.hour) * 3600 +
            (d2.minute - d1.minute) * 60 +
            (d2.second - d1.second)
        )
        nano_diff = end._nanosecond - start._nanosecond

        # Normalize nanoseconds
        if nano_diff < 0:
            time_diff -= 1
            nano_diff += 1_000_000_000

        # Normalize time into days
        if time_diff < 0:
            days -= 1
            time_diff += 86400
        elif time_diff >= 86400:
            days += time_diff // 86400
            time_diff = time_diff % 86400

        # Normalize days into months
        if days < 0:
            months -= 1
            import calendar
            prev_month = d2.month - 1 if d2.month > 1 else 12
            prev_year = d2.year if d2.month > 1 else d2.year - 1
            days += calendar.monthrange(prev_year, prev_month)[1]

        # Normalize months into years
        if months < 0:
            years -= 1
            months += 12

        total_months = years * 12 + months
        return CypherDuration(months=total_months, days=days, seconds=time_diff, nanoseconds=nano_diff)

    return CypherDuration()


def duration_inMonths(start, end) -> Optional[CypherDuration]:
    """Calculate duration in months between two temporal values."""
    # Null propagation
    if start is None or end is None:
        return None

    # Time-only types have no month component
    if isinstance(start, (CypherLocalTime, CypherTime)) or isinstance(end, (CypherLocalTime, CypherTime)):
        return CypherDuration()

    if hasattr(start, '_date'):
        d1 = start._date if isinstance(start, CypherDate) else start._datetime.date()
    elif hasattr(start, '_datetime'):
        d1 = start._datetime.date()
    else:
        return CypherDuration()

    if hasattr(end, '_date'):
        d2 = end._date if isinstance(end, CypherDate) else end._datetime.date()
    elif hasattr(end, '_datetime'):
        d2 = end._datetime.date()
    else:
        return CypherDuration()

    months = (d2.year - d1.year) * 12 + (d2.month - d1.month)

    # Determine if we need to adjust for incomplete months based on time components
    start_is_date = isinstance(start, CypherDate)
    end_is_date = isinstance(end, CypherDate)

    d1_effective = d1.day
    d2_effective = d2.day

    # Get time components (0 for pure dates)
    t1_seconds = 0
    t2_seconds = 0

    if not start_is_date and hasattr(start, '_datetime'):
        t = start._datetime.time()
        t1_seconds = t.hour * 3600 + t.minute * 60 + t.second

    if not end_is_date and hasattr(end, '_datetime'):
        t = end._datetime.time()
        t2_seconds = t.hour * 3600 + t.minute * 60 + t.second

    # Apply time adjustment based on scenarios:
    # 1. Date (midnight) vs datetime with time: time matters
    # 2. Both datetimes with significantly different times (>= 1 hour): time matters
    # 3. Both datetimes with nearly same time: time doesn't matter
    time_diff = abs(t2_seconds - t1_seconds)

    if start_is_date != end_is_date:
        # One is date (midnight), one is datetime with time
        if t2_seconds > 0:
            d2_effective += 0.5
        if t1_seconds > 0:
            d1_effective += 0.5
    elif time_diff >= 3600:  # At least 1 hour difference
        # Both are datetimes with significant time difference
        d1_effective += t1_seconds / 86400.0
        d2_effective += t2_seconds / 86400.0

    # Adjust for incomplete months based on effective day position
    if months > 0 and d2_effective < d1_effective:
        # Going forward but haven't completed the last month
        months -= 1
    elif months < 0 and d2_effective > d1_effective:
        # Going backward but haven't completed the last month
        months += 1
    return CypherDuration(months=months)


def duration_inDays(start, end) -> Optional[CypherDuration]:
    """Calculate duration in days between two temporal values."""
    # Null propagation
    if start is None or end is None:
        return None

    # Time-only types have no day component
    if isinstance(start, (CypherLocalTime, CypherTime)) or isinstance(end, (CypherLocalTime, CypherTime)):
        return CypherDuration()

    # For dates, use simple day difference
    if isinstance(start, CypherDate) and isinstance(end, CypherDate):
        delta = end._date - start._date
        return CypherDuration(days=delta.days)

    # Mixed type: Date to LocalDateTime/DateTime
    if isinstance(start, CypherDate) and isinstance(end, (CypherLocalDateTime, CypherDateTime)):
        start_dt = datetime.combine(start._date, time(0, 0, 0))
        delta = end._datetime - start_dt
        return CypherDuration(days=delta.days)

    # Mixed type: LocalDateTime/DateTime to Date
    if isinstance(start, (CypherLocalDateTime, CypherDateTime)) and isinstance(end, CypherDate):
        start_date = start._datetime.date()
        end_date = end._date
        delta = end_date - start_date

        # If start has a non-zero time and we're going forward (positive days),
        # we haven't completed the start day yet, so subtract one
        if delta.days > 0:
            t = start._datetime.time()
            if t.hour > 0 or t.minute > 0 or t.second > 0 or t.microsecond > 0:
                return CypherDuration(days=delta.days - 1)

        return CypherDuration(days=delta.days)

    # For datetimes, calculate day difference based on date portions
    if hasattr(start, '_datetime') and hasattr(end, '_datetime'):
        delta = end._datetime.date() - start._datetime.date()
        return CypherDuration(days=delta.days)

    return CypherDuration()


def duration_inSeconds(start, end) -> Optional[CypherDuration]:
    """Calculate duration in seconds between two temporal values."""
    # Null propagation
    if start is None or end is None:
        return None

    # Date to Date: convert to total seconds (days * 86400)
    if isinstance(start, CypherDate) and isinstance(end, CypherDate):
        delta = end._date - start._date
        total_seconds = delta.days * 86400
        return CypherDuration(seconds=total_seconds)

    # LocalDateTime to LocalDateTime - use NumPy for extended year support
    if isinstance(start, CypherLocalDateTime) and isinstance(end, CypherLocalDateTime):
        # Use NumPy datetime64 for calculation (supports extended years)
        delta_np = end._np_datetime - start._np_datetime
        # Convert to seconds using astype to avoid floating point precision loss
        total_seconds = int(delta_np.astype('timedelta64[s]').astype('int64'))
        nano_diff = end._nanosecond - start._nanosecond
        if nano_diff < 0:
            total_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)

    # DateTime to DateTime - account for timezones
    if isinstance(start, CypherDateTime) and isinstance(end, CypherDateTime):
        # Convert to UTC for accurate comparison
        offset1 = start._offset or timedelta(0)
        offset2 = end._offset or timedelta(0)
        d1_utc = start._datetime.replace(microsecond=0) - offset1
        d2_utc = end._datetime.replace(microsecond=0) - offset2
        delta = d2_utc - d1_utc
        total_seconds = int(delta.total_seconds())
        nano_diff = end._nanosecond - start._nanosecond
        if nano_diff < 0:
            total_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)

    # Time to Time
    if isinstance(start, (CypherLocalTime, CypherTime)) and isinstance(end, (CypherLocalTime, CypherTime)):
        start_seconds = start.hour * 3600 + start.minute * 60 + start.second
        end_seconds = end.hour * 3600 + end.minute * 60 + end.second

        # If both are CypherTime (have timezone), normalize to UTC
        if isinstance(start, CypherTime) and isinstance(end, CypherTime):
            start_offset = start._offset.total_seconds() if start._offset else 0
            end_offset = end._offset.total_seconds() if end._offset else 0
            start_seconds -= int(start_offset)
            end_seconds -= int(end_offset)

        total_seconds = end_seconds - start_seconds
        nano_diff = end._nanosecond - start._nanosecond
        # Normalize nanoseconds
        if nano_diff < 0:
            total_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)

    # Mixed type: Date to LocalDateTime/DateTime
    if isinstance(start, CypherDate) and isinstance(end, (CypherLocalDateTime, CypherDateTime)):
        # Treat date as midnight, calculate total seconds
        start_dt = datetime.combine(start._date, time(0, 0, 0))
        end_dt_no_micro = end._datetime.replace(microsecond=0)

        # If end has a named timezone, use DST-aware calculation
        end_tz_name = getattr(end, '_tz_name', None)
        if end_tz_name and ZoneInfo is not None:
            try:
                tz_info = ZoneInfo(end_tz_name)
                # Interpret both in the same timezone
                start_aware = start_dt.replace(tzinfo=tz_info)
                end_aware = end_dt_no_micro.replace(tzinfo=tz_info)
                from calendar import timegm
                total_seconds = timegm(end_aware.utctimetuple()) - timegm(start_aware.utctimetuple())
            except Exception:
                delta = end_dt_no_micro - start_dt
                total_seconds = int(delta.total_seconds())
        else:
            delta = end_dt_no_micro - start_dt
            total_seconds = int(delta.total_seconds())

        return CypherDuration(seconds=total_seconds, nanoseconds=end._nanosecond)

    # Mixed type: LocalDateTime/DateTime to Date
    if isinstance(start, (CypherLocalDateTime, CypherDateTime)) and isinstance(end, CypherDate):
        end_dt = datetime.combine(end._date, time(0, 0, 0))
        start_dt_no_micro = start._datetime.replace(microsecond=0)

        # If start has a named timezone, use DST-aware calculation
        start_tz_name = getattr(start, '_tz_name', None)
        if start_tz_name and ZoneInfo is not None:
            try:
                tz_info = ZoneInfo(start_tz_name)
                # Interpret both in the same timezone
                start_aware = start_dt_no_micro.replace(tzinfo=tz_info)
                end_aware = end_dt.replace(tzinfo=tz_info)
                from calendar import timegm
                total_seconds = timegm(end_aware.utctimetuple()) - timegm(start_aware.utctimetuple())
            except Exception:
                delta = end_dt - start_dt_no_micro
                total_seconds = int(delta.total_seconds())
        else:
            delta = end_dt - start_dt_no_micro
            total_seconds = int(delta.total_seconds())

        # Subtract nanoseconds from start
        nano = -start._nanosecond
        if nano < 0:
            total_seconds -= 1
            nano += 1_000_000_000
        return CypherDuration(seconds=total_seconds, nanoseconds=nano)

    # Mixed type: Date to Time - just the time portion
    if isinstance(start, CypherDate) and isinstance(end, (CypherLocalTime, CypherTime)):
        total_seconds = end.hour * 3600 + end.minute * 60 + end.second
        return CypherDuration(seconds=total_seconds, nanoseconds=end._nanosecond)

    # Mixed type: Time to Date - negative of the time
    if isinstance(start, (CypherLocalTime, CypherTime)) and isinstance(end, CypherDate):
        total_seconds = -(start.hour * 3600 + start.minute * 60 + start.second)
        nano = -start._nanosecond
        if nano < 0:
            total_seconds -= 1
            nano += 1_000_000_000
        return CypherDuration(seconds=total_seconds, nanoseconds=nano)

    # Mixed type: Time to LocalDateTime/DateTime
    if isinstance(start, (CypherLocalTime, CypherTime)) and isinstance(end, (CypherLocalDateTime, CypherDateTime)):
        # Check if end has a named timezone for DST-aware calculation
        end_tz_name = getattr(end, '_tz_name', None)
        if end_tz_name and ZoneInfo is not None and isinstance(start, CypherLocalTime):
            # Interpret localtime in the same timezone as datetime, on the same date
            try:
                tz_info = ZoneInfo(end_tz_name)
                start_dt = datetime.combine(end._datetime.date(), time(start.hour, start.minute, start.second))
                start_aware = start_dt.replace(tzinfo=tz_info)
                end_aware = end._datetime.replace(tzinfo=tz_info)
                from calendar import timegm
                total_seconds = timegm(end_aware.utctimetuple()) - timegm(start_aware.utctimetuple())
                nano_diff = end._nanosecond - start._nanosecond
                if nano_diff < 0:
                    total_seconds -= 1
                    nano_diff += 1_000_000_000
                return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)
            except Exception:
                pass

        # Get time components in seconds from midnight
        start_seconds = start.hour * 3600 + start.minute * 60 + start.second
        end_seconds = end.hour * 3600 + end.minute * 60 + end.second

        # If both have timezone offsets, normalize to UTC
        if isinstance(start, CypherTime) and isinstance(end, CypherDateTime):
            start_offset = start._offset.total_seconds() if start._offset else 0
            end_offset = end._offset.total_seconds() if end._offset else 0
            start_seconds -= int(start_offset)
            end_seconds -= int(end_offset)

        total_seconds = end_seconds - start_seconds
        nano_diff = end._nanosecond - start._nanosecond
        # Normalize nanoseconds
        if nano_diff < 0:
            total_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)

    # Mixed type: LocalDateTime/DateTime to Time
    if isinstance(start, (CypherLocalDateTime, CypherDateTime)) and isinstance(end, (CypherLocalTime, CypherTime)):
        # Check if start has a named timezone for DST-aware calculation
        start_tz_name = getattr(start, '_tz_name', None)
        if start_tz_name and ZoneInfo is not None and isinstance(end, CypherLocalTime):
            # Interpret localtime in the same timezone as datetime, on the same date
            try:
                tz_info = ZoneInfo(start_tz_name)
                end_dt = datetime.combine(start._datetime.date(), time(end.hour, end.minute, end.second))
                start_aware = start._datetime.replace(tzinfo=tz_info)
                end_aware = end_dt.replace(tzinfo=tz_info)
                from calendar import timegm
                total_seconds = timegm(end_aware.utctimetuple()) - timegm(start_aware.utctimetuple())
                nano_diff = end._nanosecond - start._nanosecond
                if nano_diff < 0:
                    total_seconds -= 1
                    nano_diff += 1_000_000_000
                return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)
            except Exception:
                pass

        # Get time components in seconds from midnight
        start_seconds = start.hour * 3600 + start.minute * 60 + start.second
        end_seconds = end.hour * 3600 + end.minute * 60 + end.second

        # If both have timezone offsets, normalize to UTC
        if isinstance(start, CypherDateTime) and isinstance(end, CypherTime):
            start_offset = start._offset.total_seconds() if start._offset else 0
            end_offset = end._offset.total_seconds() if end._offset else 0
            start_seconds -= int(start_offset)
            end_seconds -= int(end_offset)

        total_seconds = end_seconds - start_seconds
        nano_diff = end._nanosecond - start._nanosecond
        if nano_diff < 0:
            total_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)

    # Mixed types: DateTime/LocalDateTime to DateTime/LocalDateTime
    if hasattr(start, '_datetime') and hasattr(end, '_datetime'):
        # Check if either has a named timezone for DST-aware calculation
        start_tz_name = getattr(start, '_tz_name', None)
        end_tz_name = getattr(end, '_tz_name', None)

        d1 = start._datetime.replace(microsecond=0)
        d2 = end._datetime.replace(microsecond=0)

        # If one side has a named timezone, interpret the other in that timezone
        if start_tz_name and ZoneInfo is not None and not end_tz_name:
            # DateTime to LocalDateTime: interpret LocalDateTime in DateTime's timezone
            try:
                tz_info = ZoneInfo(start_tz_name)
                d1_aware = d1.replace(tzinfo=tz_info)
                d2_aware = d2.replace(tzinfo=tz_info)
                d1_utc = d1_aware.utctimetuple()
                d2_utc = d2_aware.utctimetuple()
                from calendar import timegm
                total_seconds = timegm(d2_utc) - timegm(d1_utc)
            except Exception:
                delta = d2 - d1
                total_seconds = int(delta.total_seconds())
        elif end_tz_name and ZoneInfo is not None and not start_tz_name:
            # LocalDateTime to DateTime: interpret LocalDateTime in DateTime's timezone
            try:
                tz_info = ZoneInfo(end_tz_name)
                d1_aware = d1.replace(tzinfo=tz_info)
                d2_aware = d2.replace(tzinfo=tz_info)
                d1_utc = d1_aware.utctimetuple()
                d2_utc = d2_aware.utctimetuple()
                from calendar import timegm
                total_seconds = timegm(d2_utc) - timegm(d1_utc)
            except Exception:
                delta = d2 - d1
                total_seconds = int(delta.total_seconds())
        else:
            # Both have same timezone type (or none) - simple difference
            delta = d2 - d1
            total_seconds = int(delta.total_seconds())

        # Handle nanoseconds if available
        start_nano = getattr(start, '_nanosecond', 0)
        end_nano = getattr(end, '_nanosecond', 0)
        nano_diff = end_nano - start_nano
        if nano_diff < 0:
            total_seconds -= 1
            nano_diff += 1_000_000_000
        return CypherDuration(seconds=total_seconds, nanoseconds=nano_diff)

    return CypherDuration()


# === Truncation functions ===

def cypher_date_truncate(unit: str, temporal_value, map_override=None) -> CypherDate:
    """Truncate date to specified unit.

    Args:
        unit: Precision unit (millennium, century, decade, year, quarter, month, week, day)
        temporal_value: CypherDate, CypherDateTime, or CypherLocalDateTime
        map_override: Optional dict to override specific components

    Returns:
        CypherDate with values truncated and optionally overridden
    """
    # Extract year/month/day from temporal_value
    if isinstance(temporal_value, CypherDate):
        year, month, day = temporal_value.year, temporal_value.month, temporal_value.day
    elif isinstance(temporal_value, (CypherDateTime, CypherLocalDateTime)):
        year = temporal_value.year
        month = temporal_value.month
        day = temporal_value.day
    else:
        # For string inputs
        if isinstance(temporal_value, str):
            temporal_value = parse_date_string(temporal_value)
            year, month, day = temporal_value.year, temporal_value.month, temporal_value.day
        else:
            year, month, day = date.today().year, 1, 1

    # Truncate based on unit
    unit = unit.lower()
    if unit == 'millennium':
        year = (year // 1000) * 1000
        month, day = 1, 1
    elif unit == 'century':
        year = (year // 100) * 100
        month, day = 1, 1
    elif unit == 'decade':
        year = (year // 10) * 10
        month, day = 1, 1
    elif unit == 'year':
        month, day = 1, 1
    elif unit == 'weekyear':
        # Truncate to first day of ISO week year (first Monday on or before Jan 4)
        d = date(year, month, day)
        iso_year = d.isocalendar()[0]
        jan4 = date(iso_year, 1, 4)
        first_monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
        year, month, day = first_monday.year, first_monday.month, first_monday.day
    elif unit == 'quarter':
        month = ((month - 1) // 3) * 3 + 1
        day = 1
    elif unit == 'month':
        day = 1
    elif unit == 'week':
        # Find start of ISO week (Monday)
        d = date(year, month, day)
        days_since_monday = d.isoweekday() - 1
        d = d - timedelta(days=days_since_monday)
        year, month, day = d.year, d.month, d.day
    elif unit == 'day':
        pass  # No truncation

    # Apply map_override if provided
    if map_override and isinstance(map_override, dict):
        year = map_override.get('year', year)
        month = map_override.get('month', month)
        day = map_override.get('day', day)

        # Handle dayOfWeek override (adjust from current day of week to target)
        if 'dayOfWeek' in map_override:
            current_dow = date(year, month, day).isoweekday()
            target_dow = map_override['dayOfWeek']
            day_adjustment = target_dow - current_dow
            adjusted_date = date(year, month, day) + timedelta(days=day_adjustment)
            year, month, day = adjusted_date.year, adjusted_date.month, adjusted_date.day

    return CypherDate(date(year, month, day))


def cypher_localtime_truncate(unit: str, temporal_value, map_override=None) -> CypherLocalTime:
    """Truncate localtime to specified unit.

    Args:
        unit: Precision unit (hour, minute, second, millisecond, microsecond)
        temporal_value: CypherLocalTime or CypherTime
        map_override: Optional dict to override specific components

    Returns:
        CypherLocalTime with values truncated and optionally overridden
    """
    # Extract time components (including nanoseconds)
    if isinstance(temporal_value, (CypherLocalTime, CypherTime)):
        hour = temporal_value.hour
        minute = temporal_value.minute
        second = temporal_value.second
        microsecond = temporal_value.microsecond
        nanosecond = temporal_value._nanosecond
    elif isinstance(temporal_value, CypherLocalDateTime):
        hour = temporal_value.hour
        minute = temporal_value.minute
        second = temporal_value.second
        microsecond = temporal_value.microsecond
        nanosecond = temporal_value._nanosecond
    elif isinstance(temporal_value, CypherDateTime):
        hour = temporal_value.hour
        minute = temporal_value.minute
        second = temporal_value.second
        microsecond = temporal_value.microsecond
        nanosecond = temporal_value._nanosecond
    else:
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0

    # Truncate based on unit
    unit = unit.lower()
    if unit == 'day':
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'hour':
        minute, second, microsecond, nanosecond = 0, 0, 0, 0
    elif unit == 'minute':
        second, microsecond, nanosecond = 0, 0, 0
    elif unit == 'second':
        microsecond, nanosecond = 0, 0
    elif unit == 'millisecond':
        microsecond = (microsecond // 1000) * 1000
        nanosecond = microsecond * 1000
    elif unit == 'microsecond':
        nanosecond = microsecond * 1000

    # Apply map_override if provided
    if map_override and isinstance(map_override, dict):
        hour = map_override.get('hour', hour)
        minute = map_override.get('minute', minute)
        second = map_override.get('second', second)
        # For sub-second overrides, preserve the truncated base and add the override
        if 'nanosecond' in map_override:
            # Add override nanoseconds to the truncated microsecond base
            base_nanos = (nanosecond // 1000) * 1000  # Truncate to microsecond
            nanosecond = base_nanos + map_override['nanosecond']
            microsecond = nanosecond // 1000
        elif 'microsecond' in map_override:
            # Add override microseconds to the truncated millisecond base
            base_micros = (microsecond // 1000) * 1000  # Truncate to millisecond
            microsecond = base_micros + map_override['microsecond']
            nanosecond = microsecond * 1000
        elif 'millisecond' in map_override:
            # Set milliseconds directly
            microsecond = map_override['millisecond'] * 1000
            nanosecond = microsecond * 1000

    return CypherLocalTime(time(hour, minute, second, microsecond), nanosecond)


def cypher_time_truncate(unit: str, temporal_value, map_override=None) -> CypherTime:
    """Truncate time to specified unit (preserving timezone).

    Args:
        unit: Precision unit (hour, minute, second, millisecond, microsecond)
        temporal_value: CypherTime
        map_override: Optional dict to override specific components

    Returns:
        CypherTime with values truncated and optionally overridden
    """
    # Extract time components and timezone (including nanoseconds)
    if isinstance(temporal_value, CypherTime):
        hour = temporal_value.hour
        minute = temporal_value.minute
        second = temporal_value.second
        microsecond = temporal_value.microsecond
        nanosecond = temporal_value._nanosecond
        offset = temporal_value._offset
    elif isinstance(temporal_value, CypherLocalTime):
        hour = temporal_value.hour
        minute = temporal_value.minute
        second = temporal_value.second
        microsecond = temporal_value.microsecond
        nanosecond = temporal_value._nanosecond
        offset = timedelta(0)  # Local time has no timezone
    elif isinstance(temporal_value, CypherDateTime):
        hour = temporal_value.hour
        minute = temporal_value.minute
        second = temporal_value.second
        microsecond = temporal_value._datetime.microsecond
        nanosecond = temporal_value._nanosecond
        offset = temporal_value._offset
    elif isinstance(temporal_value, CypherLocalDateTime):
        hour = temporal_value.hour
        minute = temporal_value.minute
        second = temporal_value.second
        microsecond = temporal_value.microsecond
        nanosecond = temporal_value._nanosecond
        offset = timedelta(0)  # Local datetime has no timezone
    else:
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
        offset = timedelta(0)

    # Truncate based on unit
    unit = unit.lower()
    if unit == 'day':
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'hour':
        minute, second, microsecond, nanosecond = 0, 0, 0, 0
    elif unit == 'minute':
        second, microsecond, nanosecond = 0, 0, 0
    elif unit == 'second':
        microsecond, nanosecond = 0, 0
    elif unit == 'millisecond':
        microsecond = (microsecond // 1000) * 1000
        nanosecond = microsecond * 1000  # Preserve millisecond precision in nanoseconds
    elif unit == 'microsecond':
        nanosecond = microsecond * 1000  # Preserve microsecond precision in nanoseconds

    # Apply map_override if provided
    if map_override and isinstance(map_override, dict):
        hour = map_override.get('hour', hour)
        minute = map_override.get('minute', minute)
        second = map_override.get('second', second)
        # For sub-second overrides, preserve the truncated base and add the override
        if 'nanosecond' in map_override:
            # Add override nanoseconds to the truncated microsecond base
            base_nanos = (nanosecond // 1000) * 1000  # Truncate to microsecond
            nanosecond = base_nanos + map_override['nanosecond']
            microsecond = nanosecond // 1000
        elif 'microsecond' in map_override:
            # Add override microseconds to the truncated millisecond base
            base_micros = (microsecond // 1000) * 1000  # Truncate to millisecond
            microsecond = base_micros + map_override['microsecond']
            nanosecond = microsecond * 1000
        elif 'millisecond' in map_override:
            # Set milliseconds directly
            microsecond = map_override['millisecond'] * 1000
            nanosecond = microsecond * 1000
        if 'timezone' in map_override:
            tz = map_override['timezone']
            if isinstance(tz, str):
                offset, _ = _parse_timezone(tz)

    return CypherTime(time(hour, minute, second, microsecond), offset, nanosecond)


def cypher_localdatetime_truncate(unit: str, temporal_value, map_override=None) -> CypherLocalDateTime:
    """Truncate localdatetime to specified unit.

    Args:
        unit: Precision unit (millennium, century, decade, year, quarter, month, week, day,
                             hour, minute, second, millisecond, microsecond)
        temporal_value: CypherLocalDateTime
        map_override: Optional dict to override specific components

    Returns:
        CypherLocalDateTime with values truncated and optionally overridden
    """
    # Extract datetime components (including nanoseconds)
    if isinstance(temporal_value, CypherLocalDateTime):
        year, month, day = temporal_value.year, temporal_value.month, temporal_value.day
        hour, minute, second = temporal_value.hour, temporal_value.minute, temporal_value.second
        microsecond = temporal_value.microsecond
        nanosecond = temporal_value._nanosecond
    elif isinstance(temporal_value, CypherDateTime):
        year, month, day = temporal_value.year, temporal_value.month, temporal_value.day
        hour, minute, second = temporal_value.hour, temporal_value.minute, temporal_value.second
        microsecond = temporal_value._datetime.microsecond
        nanosecond = temporal_value._nanosecond
    elif isinstance(temporal_value, CypherDate):
        year, month, day = temporal_value.year, temporal_value.month, temporal_value.day
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    else:
        now = datetime.now()
        year, month, day = now.year, now.month, now.day
        hour, minute, second, microsecond = now.hour, now.minute, now.second, now.microsecond
        nanosecond = microsecond * 1000

    # Truncate date components
    unit = unit.lower()
    if unit == 'millennium':
        year = (year // 1000) * 1000
        month, day = 1, 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'century':
        year = (year // 100) * 100
        month, day = 1, 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'decade':
        year = (year // 10) * 10
        month, day = 1, 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'year':
        month, day = 1, 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'weekyear':
        # Truncate to first day of ISO week year
        d = date(year, month, day)
        iso_year = d.isocalendar()[0]
        jan4 = date(iso_year, 1, 4)
        first_monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
        year, month, day = first_monday.year, first_monday.month, first_monday.day
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'quarter':
        month = ((month - 1) // 3) * 3 + 1
        day = 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'month':
        day = 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'week':
        d = date(year, month, day)
        days_since_monday = d.isoweekday() - 1
        d = d - timedelta(days=days_since_monday)
        year, month, day = d.year, d.month, d.day
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'day':
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'hour':
        minute, second, microsecond, nanosecond = 0, 0, 0, 0
    elif unit == 'minute':
        second, microsecond, nanosecond = 0, 0, 0
    elif unit == 'second':
        microsecond, nanosecond = 0, 0
    elif unit == 'millisecond':
        microsecond = (microsecond // 1000) * 1000
        nanosecond = microsecond * 1000
    elif unit == 'microsecond':
        nanosecond = microsecond * 1000

    # Apply map_override if provided
    if map_override and isinstance(map_override, dict):
        year = map_override.get('year', year)
        month = map_override.get('month', month)
        day = map_override.get('day', day)
        hour = map_override.get('hour', hour)
        minute = map_override.get('minute', minute)
        second = map_override.get('second', second)
        # For sub-second overrides, preserve the truncated base and add the override
        if 'nanosecond' in map_override:
            # Add override nanoseconds to the truncated microsecond base
            base_nanos = (nanosecond // 1000) * 1000  # Truncate to microsecond
            nanosecond = base_nanos + map_override['nanosecond']
            microsecond = nanosecond // 1000
        elif 'microsecond' in map_override:
            # Add override microseconds to the truncated millisecond base
            base_micros = (microsecond // 1000) * 1000  # Truncate to millisecond
            microsecond = base_micros + map_override['microsecond']
            nanosecond = microsecond * 1000
        elif 'millisecond' in map_override:
            # Set milliseconds directly
            microsecond = map_override['millisecond'] * 1000
            nanosecond = microsecond * 1000

        # Handle dayOfWeek override (adjust from current day of week to target)
        if 'dayOfWeek' in map_override:
            current_dow = date(year, month, day).isoweekday()
            target_dow = map_override['dayOfWeek']
            day_adjustment = target_dow - current_dow
            adjusted_date = date(year, month, day) + timedelta(days=day_adjustment)
            year, month, day = adjusted_date.year, adjusted_date.month, adjusted_date.day

    return CypherLocalDateTime(datetime(year, month, day, hour, minute, second, microsecond), nanosecond)


def cypher_datetime_truncate(unit: str, temporal_value, map_override=None) -> CypherDateTime:
    """Truncate datetime to specified unit (preserving timezone).

    Args:
        unit: Precision unit (millennium, century, decade, year, quarter, month, week, day,
                             hour, minute, second, millisecond, microsecond)
        temporal_value: CypherDateTime
        map_override: Optional dict to override specific components

    Returns:
        CypherDateTime with values truncated and optionally overridden
    """
    # Extract datetime components and timezone (including nanoseconds)
    tz_name = None
    if isinstance(temporal_value, CypherDateTime):
        year, month, day = temporal_value.year, temporal_value.month, temporal_value.day
        hour, minute, second = temporal_value.hour, temporal_value.minute, temporal_value.second
        microsecond = temporal_value._datetime.microsecond
        nanosecond = temporal_value._nanosecond
        offset = temporal_value._offset
        tz_name = temporal_value._tz_name
    elif isinstance(temporal_value, CypherLocalDateTime):
        year, month, day = temporal_value.year, temporal_value.month, temporal_value.day
        hour, minute, second = temporal_value.hour, temporal_value.minute, temporal_value.second
        microsecond = temporal_value.microsecond
        nanosecond = temporal_value._nanosecond
        offset = timedelta(0)
    elif isinstance(temporal_value, CypherDate):
        year, month, day = temporal_value.year, temporal_value.month, temporal_value.day
        hour, minute, second, microsecond = 0, 0, 0, 0
        nanosecond = 0
        offset = timedelta(0)
    else:
        now = datetime.now()
        year, month, day = now.year, now.month, now.day
        hour, minute, second, microsecond = now.hour, now.minute, now.second, now.microsecond
        nanosecond = microsecond * 1000
        offset = timedelta(0)

    # Truncate date components (same as localdatetime)
    unit = unit.lower()
    if unit == 'millennium':
        year = (year // 1000) * 1000
        month, day = 1, 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'century':
        year = (year // 100) * 100
        month, day = 1, 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'decade':
        year = (year // 10) * 10
        month, day = 1, 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'year':
        month, day = 1, 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'weekyear':
        # Truncate to first day of ISO week year
        d = date(year, month, day)
        iso_year = d.isocalendar()[0]
        jan4 = date(iso_year, 1, 4)
        first_monday = jan4 - timedelta(days=jan4.isoweekday() - 1)
        year, month, day = first_monday.year, first_monday.month, first_monday.day
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'quarter':
        month = ((month - 1) // 3) * 3 + 1
        day = 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'month':
        day = 1
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'week':
        d = date(year, month, day)
        days_since_monday = d.isoweekday() - 1
        d = d - timedelta(days=days_since_monday)
        year, month, day = d.year, d.month, d.day
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'day':
        hour, minute, second, microsecond, nanosecond = 0, 0, 0, 0, 0
    elif unit == 'hour':
        minute, second, microsecond, nanosecond = 0, 0, 0, 0
    elif unit == 'minute':
        second, microsecond, nanosecond = 0, 0, 0
    elif unit == 'second':
        microsecond, nanosecond = 0, 0
    elif unit == 'millisecond':
        microsecond = (microsecond // 1000) * 1000
        nanosecond = microsecond * 1000
    elif unit == 'microsecond':
        nanosecond = microsecond * 1000

    # Apply map_override if provided
    if map_override and isinstance(map_override, dict):
        year = map_override.get('year', year)
        month = map_override.get('month', month)
        day = map_override.get('day', day)
        hour = map_override.get('hour', hour)
        minute = map_override.get('minute', minute)
        second = map_override.get('second', second)
        # For sub-second overrides, preserve the truncated base and add the override
        if 'nanosecond' in map_override:
            # Add override nanoseconds to the truncated microsecond base
            base_nanos = (nanosecond // 1000) * 1000  # Truncate to microsecond
            nanosecond = base_nanos + map_override['nanosecond']
            microsecond = nanosecond // 1000
        elif 'microsecond' in map_override:
            # Add override microseconds to the truncated millisecond base
            base_micros = (microsecond // 1000) * 1000  # Truncate to millisecond
            microsecond = base_micros + map_override['microsecond']
            nanosecond = microsecond * 1000
        elif 'millisecond' in map_override:
            # Set milliseconds directly
            microsecond = map_override['millisecond'] * 1000
            nanosecond = microsecond * 1000
        if 'timezone' in map_override:
            tz = map_override['timezone']
            if isinstance(tz, str):
                offset, parsed_tz_name = _parse_timezone(tz)
                tz_name = parsed_tz_name

        # Handle dayOfWeek override (adjust from current day of week to target)
        if 'dayOfWeek' in map_override:
            current_dow = date(year, month, day).isoweekday()
            target_dow = map_override['dayOfWeek']
            day_adjustment = target_dow - current_dow
            adjusted_date = date(year, month, day) + timedelta(days=day_adjustment)
            year, month, day = adjusted_date.year, adjusted_date.month, adjusted_date.day

    return CypherDateTime(datetime(year, month, day, hour, minute, second, microsecond), offset, nanosecond, tz_name)


# === Current time functions ===
# These functions accept an optional QueryClock for statement/transaction-scoped times.

def cypher_date_statement(clock: Optional[QueryClock] = None) -> CypherDate:
    """Return current date at statement start."""
    if clock is not None:
        return CypherDate(clock.statement_time.date())
    return CypherDate(date.today())


def cypher_date_transaction(clock: Optional[QueryClock] = None) -> CypherDate:
    """Return current date at transaction start."""
    if clock is not None:
        return CypherDate(clock.transaction_time.date())
    return CypherDate(date.today())


def cypher_date_realtime(clock: Optional[QueryClock] = None) -> CypherDate:
    """Return current date in realtime."""
    if clock is not None:
        return CypherDate(clock.realtime.date())
    return CypherDate(date.today())


def cypher_localtime_statement(clock: Optional[QueryClock] = None) -> CypherLocalTime:
    """Return current localtime at statement start."""
    if clock is not None:
        return CypherLocalTime(clock.statement_time.time())
    return CypherLocalTime(datetime.now().time())


def cypher_localtime_transaction(clock: Optional[QueryClock] = None) -> CypherLocalTime:
    """Return current localtime at transaction start."""
    if clock is not None:
        return CypherLocalTime(clock.transaction_time.time())
    return CypherLocalTime(datetime.now().time())


def cypher_localtime_realtime(clock: Optional[QueryClock] = None) -> CypherLocalTime:
    """Return current localtime in realtime."""
    if clock is not None:
        return CypherLocalTime(clock.realtime.time())
    return CypherLocalTime(datetime.now().time())


def cypher_time_statement(clock: Optional[QueryClock] = None) -> CypherTime:
    """Return current time at statement start (with system timezone)."""
    if clock is not None:
        return CypherTime(clock.statement_time.time(), timedelta(0))
    return CypherTime(datetime.now().time(), timedelta(0))


def cypher_time_transaction(clock: Optional[QueryClock] = None) -> CypherTime:
    """Return current time at transaction start (with system timezone)."""
    if clock is not None:
        return CypherTime(clock.transaction_time.time(), timedelta(0))
    return CypherTime(datetime.now().time(), timedelta(0))


def cypher_time_realtime(clock: Optional[QueryClock] = None) -> CypherTime:
    """Return current time in realtime (with system timezone)."""
    if clock is not None:
        return CypherTime(clock.realtime.time(), timedelta(0))
    return CypherTime(datetime.now().time(), timedelta(0))


def cypher_localdatetime_statement(clock: Optional[QueryClock] = None) -> CypherLocalDateTime:
    """Return current localdatetime at statement start."""
    if clock is not None:
        return CypherLocalDateTime(clock.statement_time)
    return CypherLocalDateTime(datetime.now())


def cypher_localdatetime_transaction(clock: Optional[QueryClock] = None) -> CypherLocalDateTime:
    """Return current localdatetime at transaction start."""
    if clock is not None:
        return CypherLocalDateTime(clock.transaction_time)
    return CypherLocalDateTime(datetime.now())


def cypher_localdatetime_realtime(clock: Optional[QueryClock] = None) -> CypherLocalDateTime:
    """Return current localdatetime in realtime."""
    if clock is not None:
        return CypherLocalDateTime(clock.realtime)
    return CypherLocalDateTime(datetime.now())


def cypher_datetime_statement(clock: Optional[QueryClock] = None) -> CypherDateTime:
    """Return current datetime at statement start (with system timezone)."""
    if clock is not None:
        return CypherDateTime(clock.statement_time, timedelta(0))
    return CypherDateTime(datetime.now(), timedelta(0))


def cypher_datetime_transaction(clock: Optional[QueryClock] = None) -> CypherDateTime:
    """Return current datetime at transaction start (with system timezone)."""
    if clock is not None:
        return CypherDateTime(clock.transaction_time, timedelta(0))
    return CypherDateTime(datetime.now(), timedelta(0))


def cypher_datetime_realtime(clock: Optional[QueryClock] = None) -> CypherDateTime:
    """Return current datetime in realtime (with system timezone)."""
    if clock is not None:
        return CypherDateTime(clock.realtime, timedelta(0))
    return CypherDateTime(datetime.now(), timedelta(0))
