"""TypedValue encoding and decoding for GQLDB Python driver."""

from __future__ import annotations

import calendar
import json
import math
import re
import struct
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Any, Dict, List, Optional, Set, Union

from .enums import PropertyType
from .wrappers import Int32, UInt32, Float32, UInt64
from .data_types import (
    Point,
    Point3D,
    GqldbDecimal,
    GqldbLocalDateTime,
    GqldbDate,
    GqldbZonedDateTime,
    GqldbLocalTime,
    GqldbZonedTime,
    YearToMonth,
    DayToSecond,
    Vector,
    GqldbTable,
)
from .graph_models import GqldbNode, GqldbEdge, GqldbPath, GqldbError

@dataclass
class TypedValue:
    """Represents a typed value for wire transmission."""
    type: PropertyType
    data: bytes
    is_null: bool = False

    @classmethod
    def from_value(cls, value: Any) -> "TypedValue":
        """Create a TypedValue from a Python value."""
        if isinstance(value, TypedValue):
            return value

        if value is None:
            return cls(type=PropertyType.NULL, data=b"", is_null=True)

        if isinstance(value, bool):
            data = struct.pack("<B", 1 if value else 0)
            return cls(type=PropertyType.BOOL, data=data)

        # Handle explicit type wrappers (must check before generic int/float)
        if isinstance(value, Int32):
            data = struct.pack("<i", value.value)
            return cls(type=PropertyType.INT32, data=data)

        if isinstance(value, UInt32):
            data = struct.pack("<I", value.value)
            return cls(type=PropertyType.UINT32, data=data)

        if isinstance(value, Float32):
            data = struct.pack("<f", value.value)
            return cls(type=PropertyType.FLOAT, data=data)

        if isinstance(value, UInt64):
            data = struct.pack("<Q", value.value)
            return cls(type=PropertyType.UINT64, data=data)

        if isinstance(value, int):
            # Use int64 for integers
            data = struct.pack("<q", value)
            return cls(type=PropertyType.INT64, data=data)

        if isinstance(value, float):
            data = struct.pack("<d", value)
            return cls(type=PropertyType.DOUBLE, data=data)

        if isinstance(value, str):
            return cls(type=PropertyType.STRING, data=value.encode("utf-8"))

        if isinstance(value, bytes):
            return cls(type=PropertyType.BLOB, data=value)

        if isinstance(value, datetime):
            if value.tzinfo is not None:
                # datetime with tzinfo -> ZONED_DATETIME
                offset_td = value.utcoffset()
                offset_minutes = int(offset_td.total_seconds()) // 60
                nanos = value.microsecond * 1000
                data = struct.pack("<HBBBBBIh", value.year, value.month, value.day,
                                   value.hour, value.minute, value.second,
                                   nanos, offset_minutes)
                return cls(type=PropertyType.ZONED_DATETIME, data=data)
            else:
                # datetime without tzinfo -> LOCAL_DATETIME
                nanos = value.microsecond * 1000
                data = struct.pack("<HBBBBBI", value.year, value.month, value.day,
                                   value.hour, value.minute, value.second, nanos)
                return cls(type=PropertyType.LOCAL_DATETIME, data=data)

        if isinstance(value, date):
            # Binary format: year(2), month(1), day(1), pad(4)
            data = struct.pack("<HBB4x", value.year, value.month, value.day)
            return cls(type=PropertyType.DATE, data=data)

        if isinstance(value, time):
            if value.tzinfo is not None:
                offset_td = value.utcoffset()
                offset_minutes = int(offset_td.total_seconds()) // 60
                nanos = value.microsecond * 1000
                data = struct.pack("<BBBxIh", value.hour, value.minute, value.second,
                                   nanos, offset_minutes)
                return cls(type=PropertyType.ZONED_TIME, data=data)
            else:
                nanos = value.microsecond * 1000
                data = struct.pack("<BBBxI", value.hour, value.minute, value.second, nanos)
                return cls(type=PropertyType.LOCAL_TIME, data=data)

        if isinstance(value, timedelta):
            total_secs = int(value.total_seconds())
            remaining_micros = value.microseconds  # microsecond component only
            nanos = remaining_micros * 1000
            # timedelta stores as days + seconds + microseconds internally
            total_whole_secs = value.days * 86400 + value.seconds
            data = struct.pack("<QI", total_whole_secs, nanos)
            return cls(type=PropertyType.DAY_TO_SECOND, data=data)

        if isinstance(value, list):
            return cls._encode_list(value)

        if isinstance(value, set):
            return cls._encode_set(value)

        if isinstance(value, Point):
            # Binary format: longitude, latitude (8 bytes each)
            data = struct.pack("<dd", value.longitude, value.latitude)
            return cls(type=PropertyType.POINT, data=data)

        if isinstance(value, GqldbDate):
            # Binary format: year(2), month(1), day(1), pad(4)
            data = struct.pack("<HBB4x", value.year, value.month, value.day)
            return cls(type=PropertyType.DATE, data=data)

        if isinstance(value, Point3D):
            data = struct.pack("<ddd", value.x, value.y, value.z)
            return cls(type=PropertyType.POINT3D, data=data)

        if isinstance(value, GqldbDecimal):
            return cls(type=PropertyType.DECIMAL, data=value.value.encode("utf-8"))

        if isinstance(value, GqldbLocalDateTime):
            # Component-based encoding: 11 bytes
            # [year:2 LE][month:1][day:1][hour:1][min:1][sec:1][nanos:4 LE]
            data = struct.pack("<HBBBBBI", value.year, value.month, value.day,
                               value.hour, value.minute, value.second, value.nanosecond)
            return cls(type=PropertyType.LOCAL_DATETIME, data=data)

        if isinstance(value, GqldbZonedDateTime):
            # Component-based encoding: 13 bytes
            # [year:2 LE][month:1][day:1][hour:1][min:1][sec:1][nanos:4 LE][offset:2 LE]
            data = struct.pack("<HBBBBBIh", value.year, value.month, value.day,
                               value.hour, value.minute, value.second,
                               value.nanosecond, value.offset_minutes)
            return cls(type=PropertyType.ZONED_DATETIME, data=data)

        if isinstance(value, GqldbLocalTime):
            # Binary format: hour(1), minute(1), second(1), pad(1), nanosecond(4)
            data = struct.pack("<BBBxI", value.hour, value.minute, value.second, value.nanosecond)
            return cls(type=PropertyType.LOCAL_TIME, data=data)

        if isinstance(value, GqldbZonedTime):
            # Binary format: hour(1), minute(1), second(1), pad(1), nanosecond(4), offset_minutes(2)
            data = struct.pack("<BBBxIh", value.hour, value.minute, value.second, value.nanosecond, value.offset_minutes)
            return cls(type=PropertyType.ZONED_TIME, data=data)

        if isinstance(value, YearToMonth):
            data = struct.pack("<i", value.months)
            return cls(type=PropertyType.YEAR_TO_MONTH, data=data)

        if isinstance(value, DayToSecond):
            # Binary format: seconds(8), nanoseconds(4)
            data = struct.pack("<QI", value.seconds, value.nanoseconds)
            return cls(type=PropertyType.DAY_TO_SECOND, data=data)

        if isinstance(value, Vector):
            data = bytearray()
            data.extend(struct.pack("<I", len(value.values)))
            for v in value.values:
                data.extend(struct.pack("<f", v))
            return cls(type=PropertyType.VECTOR, data=bytes(data))

        if isinstance(value, dict):
            return cls._encode_map(value)

        raise TypeError(f"Unsupported type: {type(value)}")

    def to_python(self) -> Any:
        """Convert TypedValue to a Python value."""
        if self.is_null or self.type == PropertyType.NULL:
            return None

        if self.type == PropertyType.BOOL:
            return self.data[0] != 0 if len(self.data) >= 1 else False

        if self.type == PropertyType.INT32:
            return struct.unpack("<i", self.data)[0] if len(self.data) >= 4 else 0

        if self.type == PropertyType.UINT32:
            return struct.unpack("<I", self.data)[0] if len(self.data) >= 4 else 0

        if self.type == PropertyType.INT64:
            return struct.unpack("<q", self.data)[0] if len(self.data) >= 8 else 0

        if self.type == PropertyType.UINT64:
            return struct.unpack("<Q", self.data)[0] if len(self.data) >= 8 else 0

        if self.type == PropertyType.FLOAT:
            return struct.unpack("<f", self.data)[0] if len(self.data) >= 4 else 0.0

        if self.type == PropertyType.DOUBLE:
            return struct.unpack("<d", self.data)[0] if len(self.data) >= 8 else 0.0

        if self.type in (PropertyType.STRING, PropertyType.TEXT):
            try:
                return self.data.decode("utf-8")
            except UnicodeDecodeError:
                # Database may return binary data as STRING type
                # Return raw bytes in this case
                return self.data

        if self.type == PropertyType.BLOB:
            return self.data

        if self.type == PropertyType.TIMESTAMP:
            if len(self.data) >= 12:
                secs, nanos = struct.unpack("<qI", self.data[0:12])
                return datetime.fromtimestamp(secs, tz=timezone.utc).replace(
                    microsecond=nanos // 1000)
            return datetime.min.replace(tzinfo=timezone.utc)

        if self.type == PropertyType.DATE:
            if len(self.data) >= 4:
                year = struct.unpack("<H", self.data[0:2])[0]
                month = self.data[2]
                day = self.data[3]
                return GqldbDate(year=year, month=month, day=day)
            return GqldbDate(year=1970, month=1, day=1)

        if self.type == PropertyType.LIST:
            return self._decode_list()

        if self.type == PropertyType.MAP:
            return self._decode_map()

        if self.type == PropertyType.POINT:
            if len(self.data) >= 16:
                lng, lat = struct.unpack("<dd", self.data[:16])
                return Point(latitude=lat, longitude=lng)
            return Point(latitude=0.0, longitude=0.0)

        if self.type == PropertyType.POINT3D:
            if len(self.data) >= 24:
                x, y, z = struct.unpack("<ddd", self.data[:24])
                return Point3D(x=x, y=y, z=z)
            return Point3D(x=0.0, y=0.0, z=0.0)

        if self.type == PropertyType.DECIMAL:
            return GqldbDecimal(value=self.data.decode("utf-8"))

        if self.type == PropertyType.SET:
            return set(self._decode_list())

        if self.type in (PropertyType.LOCAL_DATETIME, PropertyType.DATETIME):
            if len(self.data) >= 11:
                year, month, day, hour, minute, second = struct.unpack("<HBBBBB", self.data[0:7])
                nanosecond = struct.unpack("<I", self.data[7:11])[0]
                return GqldbLocalDateTime(year=year, month=month, day=day,
                                          hour=hour, minute=minute, second=second,
                                          nanosecond=nanosecond)
            return GqldbLocalDateTime(year=1970, month=1, day=1, hour=0, minute=0, second=0, nanosecond=0)

        if self.type == PropertyType.ZONED_DATETIME:
            if len(self.data) >= 13:
                year, month, day, hour, minute, second = struct.unpack("<HBBBBB", self.data[0:7])
                nanosecond = struct.unpack("<I", self.data[7:11])[0]
                offset_minutes = struct.unpack("<h", self.data[11:13])[0]
                return GqldbZonedDateTime(year=year, month=month, day=day,
                                          hour=hour, minute=minute, second=second,
                                          nanosecond=nanosecond, offset_minutes=offset_minutes)
            return GqldbZonedDateTime(year=1970, month=1, day=1, hour=0, minute=0, second=0,
                                      nanosecond=0, offset_minutes=0)

        if self.type == PropertyType.LOCAL_TIME:
            if len(self.data) >= 8:
                hour = self.data[0]
                minute = self.data[1]
                second = self.data[2]
                nanosecond = struct.unpack("<I", self.data[4:8])[0]
                return GqldbLocalTime(hour=hour, minute=minute, second=second, nanosecond=nanosecond)
            return GqldbLocalTime(hour=0, minute=0, second=0, nanosecond=0)

        if self.type == PropertyType.ZONED_TIME:
            if len(self.data) >= 10:
                hour = self.data[0]
                minute = self.data[1]
                second = self.data[2]
                nanosecond = struct.unpack("<I", self.data[4:8])[0]
                offset_minutes = struct.unpack("<h", self.data[8:10])[0]
                return GqldbZonedTime(hour=hour, minute=minute, second=second, nanosecond=nanosecond, offset_minutes=offset_minutes)
            return GqldbZonedTime(hour=0, minute=0, second=0, nanosecond=0, offset_minutes=0)

        if self.type == PropertyType.YEAR_TO_MONTH:
            if len(self.data) >= 4:
                months = struct.unpack("<i", self.data[:4])[0]
                return YearToMonth(months=months)
            return YearToMonth(months=0)

        if self.type == PropertyType.DAY_TO_SECOND:
            if len(self.data) >= 12:
                seconds = struct.unpack("<Q", self.data[0:8])[0]
                nanoseconds = struct.unpack("<I", self.data[8:12])[0]
                return DayToSecond(seconds=seconds, nanoseconds=nanoseconds)
            return DayToSecond(seconds=0, nanoseconds=0)

        if self.type == PropertyType.VECTOR:
            if len(self.data) >= 4:
                dim = struct.unpack("<I", self.data[:4])[0]
                if len(self.data) >= 4 + dim * 4:
                    values = []
                    for i in range(dim):
                        v = struct.unpack("<f", self.data[4 + i*4:8 + i*4])[0]
                        values.append(v)
                    return Vector(values=values)
            return Vector(values=[])

        if self.type == PropertyType.RECORD:
            return self._decode_map()

        if self.type == PropertyType.TABLE:
            return self._decode_table()

        if self.type == PropertyType.PATH:
            return self._decode_path()

        if self.type == PropertyType.ERROR:
            return self._decode_error()

        if self.type == PropertyType.NODE:
            return self._decode_node()

        if self.type == PropertyType.EDGE:
            return self._decode_edge()

        # For unsupported types, return raw bytes
        return self.data

    def format_value(self) -> str:
        """Serialize TypedValue to canonical string representation."""
        if self.is_null or self.type == PropertyType.NULL:
            return ""

        val = self.to_python()

        if self.type == PropertyType.BOOL:
            return "true" if val else "false"

        if self.type in (PropertyType.INT32, PropertyType.UINT32, PropertyType.INT64,
                         PropertyType.UINT64, PropertyType.FLOAT, PropertyType.DOUBLE):
            return str(val)

        if self.type in (PropertyType.STRING, PropertyType.TEXT):
            return val if isinstance(val, str) else val.decode("utf-8")

        if self.type == PropertyType.DECIMAL:
            return val.value

        if self.type == PropertyType.DATE:
            return f"{val.year:04d}-{val.month:02d}-{val.day:02d}"

        if self.type == PropertyType.LOCAL_TIME:
            return _format_time_component(val.hour, val.minute, val.second, val.nanosecond)

        if self.type == PropertyType.ZONED_TIME:
            t = _format_time_component(val.hour, val.minute, val.second, val.nanosecond)
            return t + _format_offset(val.offset_minutes)

        if self.type in (PropertyType.LOCAL_DATETIME, PropertyType.DATETIME):
            d = f"{val.year:04d}-{val.month:02d}-{val.day:02d}"
            t = _format_time_component(val.hour, val.minute, val.second, val.nanosecond)
            return d + " " + t

        if self.type == PropertyType.ZONED_DATETIME:
            d = f"{val.year:04d}-{val.month:02d}-{val.day:02d}"
            t = _format_time_component(val.hour, val.minute, val.second, val.nanosecond)
            return d + " " + t + _format_offset(val.offset_minutes)

        if self.type == PropertyType.TIMESTAMP:
            # val is a datetime in UTC
            nanos = val.microsecond * 1000
            d = f"{val.year:04d}-{val.month:02d}-{val.day:02d}"
            t = _format_time_component(val.hour, val.minute, val.second, nanos)
            return d + " " + t

        if self.type == PropertyType.YEAR_TO_MONTH:
            months = val.months
            if months == 0:
                return "P0M"
            neg = months < 0
            abs_months = abs(months)
            years = abs_months // 12
            rem = abs_months % 12
            s = "-P" if neg else "P"
            if years > 0:
                s += f"{years}Y"
            if rem > 0:
                s += f"{rem}M"
            return s

        if self.type == PropertyType.DAY_TO_SECOND:
            return _format_day_to_second(val.seconds, val.nanoseconds)

        # Fallback
        return str(val)

    @staticmethod
    def from_string(s: str, target_type: PropertyType) -> "TypedValue":
        """Parse a string into a TypedValue for the given target type."""
        if s == "":
            return TypedValue(type=target_type, data=b"", is_null=True)

        if target_type == PropertyType.NULL:
            return TypedValue(type=PropertyType.NULL, data=b"", is_null=True)

        if target_type == PropertyType.BOOL:
            val = s.lower() in ("true", "1")
            return TypedValue.from_value(val)

        if target_type == PropertyType.INT32:
            return TypedValue(type=PropertyType.INT32, data=struct.pack("<i", int(s)))

        if target_type == PropertyType.UINT32:
            return TypedValue(type=PropertyType.UINT32, data=struct.pack("<I", int(s)))

        if target_type == PropertyType.INT64:
            return TypedValue(type=PropertyType.INT64, data=struct.pack("<q", int(s)))

        if target_type == PropertyType.UINT64:
            return TypedValue(type=PropertyType.UINT64, data=struct.pack("<Q", int(s)))

        if target_type == PropertyType.FLOAT:
            return TypedValue(type=PropertyType.FLOAT, data=struct.pack("<f", float(s)))

        if target_type == PropertyType.DOUBLE:
            return TypedValue(type=PropertyType.DOUBLE, data=struct.pack("<d", float(s)))

        if target_type in (PropertyType.STRING, PropertyType.TEXT):
            return TypedValue(type=target_type, data=s.encode("utf-8"))

        if target_type == PropertyType.DECIMAL:
            return TypedValue.from_value(GqldbDecimal(value=s))

        if target_type == PropertyType.DATE:
            parts = s.split("-")
            year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
            return TypedValue.from_value(GqldbDate(year=year, month=month, day=day))

        if target_type == PropertyType.LOCAL_TIME:
            clean = _remove_offset(s)
            hour, minute, second, nanos = _parse_time_string(clean)
            return TypedValue.from_value(GqldbLocalTime(hour=hour, minute=minute, second=second, nanosecond=nanos))

        if target_type == PropertyType.ZONED_TIME:
            time_part, offset_minutes = _split_time_and_offset(s)
            hour, minute, second, nanos = _parse_time_string(time_part)
            return TypedValue.from_value(GqldbZonedTime(hour=hour, minute=minute, second=second,
                                                         nanosecond=nanos, offset_minutes=offset_minutes))

        if target_type in (PropertyType.LOCAL_DATETIME, PropertyType.DATETIME):
            clean = _remove_offset(s)
            sep = "T" if "T" in clean else " "
            date_part, time_part = clean.split(sep, 1)
            dp = date_part.split("-")
            year, month, day = int(dp[0]), int(dp[1]), int(dp[2])
            hour, minute, second, nanos = _parse_time_string(time_part)
            return TypedValue.from_value(GqldbLocalDateTime(year=year, month=month, day=day,
                                                             hour=hour, minute=minute, second=second,
                                                             nanosecond=nanos))

        if target_type == PropertyType.ZONED_DATETIME:
            normalized = _normalize_offset(s)
            sep = "T" if "T" in normalized else " "
            date_part, rest = normalized.split(sep, 1)
            time_part, offset_minutes = _split_time_and_offset(rest)
            dp = date_part.split("-")
            year, month, day = int(dp[0]), int(dp[1]), int(dp[2])
            hour, minute, second, nanos = _parse_time_string(time_part)
            return TypedValue.from_value(GqldbZonedDateTime(year=year, month=month, day=day,
                                                             hour=hour, minute=minute, second=second,
                                                             nanosecond=nanos, offset_minutes=offset_minutes))

        if target_type == PropertyType.TIMESTAMP:
            # Support epoch seconds as string
            if s.isdigit() or (s.startswith("-") and s[1:].isdigit()):
                epoch = int(s)
                data = struct.pack("<qI", epoch, 0)
                return TypedValue(type=PropertyType.TIMESTAMP, data=data)
            # Handle Z suffix (treat as UTC, +00:00)
            if s.endswith("Z"):
                s = s[:-1] + "+00:00"
            # Check for timezone offset and convert to UTC if present
            # Allow optional space before offset
            tz_match = re.search(r'\s?([+-])(\d{2}):?(\d{2})$', s)
            if tz_match:
                dt_part = s[:tz_match.start()]
                sign = 1 if tz_match.group(1) == "+" else -1
                oh = int(tz_match.group(2))
                om = int(tz_match.group(3))
                offset_minutes = sign * (oh * 60 + om)
                # Split date and time from the datetime portion
                sep = "T" if "T" in dt_part else " "
                date_part, time_part = dt_part.split(sep, 1)
                dp = date_part.split("-")
                year, month, day = int(dp[0]), int(dp[1]), int(dp[2])
                hour, minute, second, nanos = _parse_time_string(time_part)
                # Build a datetime with the offset, then convert to UTC
                tz_offset = timezone(timedelta(minutes=offset_minutes))
                local_dt = datetime(year, month, day, hour, minute, second,
                                    nanos // 1000, tzinfo=tz_offset)
                utc_dt = local_dt.astimezone(timezone.utc)
                utc_secs = int(utc_dt.replace(tzinfo=timezone.utc).timestamp())
                data = struct.pack("<qI", utc_secs, nanos)
                return TypedValue(type=PropertyType.TIMESTAMP, data=data)
            # Otherwise parse "yyyy-MM-dd HH:mm:ss[.n]" (no timezone, treated as UTC)
            sep = "T" if "T" in s else " "
            date_part, time_part = s.split(sep, 1)
            dp = date_part.split("-")
            year, month, day = int(dp[0]), int(dp[1]), int(dp[2])
            hour, minute, second, nanos = _parse_time_string(time_part)
            secs = calendar.timegm((year, month, day, hour, minute, second, 0, 0, 0))
            data = struct.pack("<qI", secs, nanos)
            return TypedValue(type=PropertyType.TIMESTAMP, data=data)

        if target_type == PropertyType.YEAR_TO_MONTH:
            months = _parse_year_to_month(s)
            return TypedValue.from_value(YearToMonth(months=months))

        if target_type == PropertyType.DAY_TO_SECOND:
            secs, nanos = _parse_day_to_second(s)
            return TypedValue.from_value(DayToSecond(seconds=secs, nanoseconds=nanos))

        raise ValueError(f"Unsupported target type for from_string: {target_type}")

    def _decode_path(self) -> "GqldbPath":
        """Decode a path from binary format.

        Binary format: [nodeCount:2][nodeLen:4][nodeData]...[edgeCount:2][edgeLen:4][edgeData]...
        """
        offset = 0
        nodes = []
        edges = []

        try:
            # Decode nodes
            if offset + 2 > len(self.data):
                return GqldbPath(nodes=nodes, edges=edges)
            node_count = struct.unpack("<H", self.data[offset:offset+2])[0]
            offset += 2

            for _ in range(node_count):
                if offset + 4 > len(self.data):
                    break
                node_len = struct.unpack("<I", self.data[offset:offset+4])[0]
                offset += 4
                if offset + node_len > len(self.data):
                    break
                node_data = self.data[offset:offset+node_len]
                nodes.append(self._decode_node_binary(node_data))
                offset += node_len

            # Decode edges
            if offset + 2 > len(self.data):
                return GqldbPath(nodes=nodes, edges=edges)
            edge_count = struct.unpack("<H", self.data[offset:offset+2])[0]
            offset += 2

            for _ in range(edge_count):
                if offset + 4 > len(self.data):
                    break
                edge_len = struct.unpack("<I", self.data[offset:offset+4])[0]
                offset += 4
                if offset + edge_len > len(self.data):
                    break
                edge_data = self.data[offset:offset+edge_len]
                edges.append(self._decode_edge_binary(edge_data))
                offset += edge_len

            return GqldbPath(nodes=nodes, edges=edges)
        except Exception:
            return GqldbPath()

    def _decode_error(self) -> "GqldbError":
        """Decode an error from JSON bytes."""
        try:
            json_str = self.data.decode("utf-8")
            err_data = json.loads(json_str)
            return GqldbError(
                code=err_data.get("code", 0),
                message=err_data.get("message", json_str)
            )
        except Exception:
            # If not valid JSON, treat as plain message
            return GqldbError(message=self.data.decode("utf-8"))

    def _decode_node(self) -> "GqldbNode":
        """Decode a node from binary format.

        Binary format: [idLen:2][id][labelCount:2][labelLen:2][label]...[properties_binary]
        """
        return self._decode_node_binary(self.data)

    def _decode_node_binary(self, data: bytes) -> "GqldbNode":
        """Decode a node from binary data."""
        offset = 0
        try:
            # Decode ID
            node_id, id_len = self._decode_string_from(data, offset)
            if id_len == 0:
                return GqldbNode()
            offset += id_len

            # Decode labels
            if offset + 2 > len(data):
                return GqldbNode(id=node_id)
            label_count = struct.unpack("<H", data[offset:offset+2])[0]
            offset += 2

            labels = []
            for _ in range(label_count):
                label, label_len = self._decode_string_from(data, offset)
                if label_len == 0:
                    break
                labels.append(label)
                offset += label_len

            # Decode properties
            properties = self._decode_properties_binary(data, offset)

            return GqldbNode(id=node_id, labels=labels, properties=properties)
        except Exception:
            return GqldbNode()

    def _decode_edge(self) -> "GqldbEdge":
        """Decode an edge from binary format.

        Binary format: [idLen:2][id][labelLen:2][label][fromLen:2][from][toLen:2][to][properties_binary]
        """
        return self._decode_edge_binary(self.data)

    def _decode_edge_binary(self, data: bytes) -> "GqldbEdge":
        """Decode an edge from binary data."""
        offset = 0
        try:
            # Decode ID
            edge_id, id_len = self._decode_string_from(data, offset)
            if id_len == 0:
                return GqldbEdge()
            offset += id_len

            # Decode label
            label, label_len = self._decode_string_from(data, offset)
            if label_len == 0:
                return GqldbEdge(id=edge_id)
            offset += label_len

            # Decode from_node_id
            from_id, from_len = self._decode_string_from(data, offset)
            if from_len == 0:
                return GqldbEdge(id=edge_id, label=label)
            offset += from_len

            # Decode to_node_id
            to_id, to_len = self._decode_string_from(data, offset)
            if to_len == 0:
                return GqldbEdge(id=edge_id, label=label, from_node_id=from_id)
            offset += to_len

            # Decode properties
            properties = self._decode_properties_binary(data, offset)

            return GqldbEdge(id=edge_id, label=label, from_node_id=from_id, to_node_id=to_id, properties=properties)
        except Exception:
            return GqldbEdge()

    def _decode_table(self) -> "GqldbTable":
        """Decode a table from binary format.

        Binary format: [colCount:2][colLen:2][col]...[rowCount:2][cellCount:2][TVE]...
        """
        offset = 0
        columns = []
        rows = []
        column_types = []

        try:
            # Decode columns
            if offset + 2 > len(self.data):
                return GqldbTable()
            col_count = struct.unpack("<H", self.data[offset:offset+2])[0]
            offset += 2

            for _ in range(col_count):
                col, col_len = self._decode_string_from(self.data, offset)
                if col_len == 0:
                    break
                columns.append(col)
                offset += col_len

            # Decode rows
            if offset + 2 > len(self.data):
                return GqldbTable(columns=columns)
            row_count = struct.unpack("<H", self.data[offset:offset+2])[0]
            offset += 2

            for row_idx in range(row_count):
                if offset + 2 > len(self.data):
                    break
                cell_count = struct.unpack("<H", self.data[offset:offset+2])[0]
                offset += 2

                row = []
                for cell_idx in range(cell_count):
                    tv, tv_len = self._decode_tve_from(self.data, offset)
                    if tv_len == 0:
                        break
                    row.append(tv.to_python())
                    # Capture column types from first row
                    if row_idx == 0:
                        column_types.append(tv.type)
                    offset += tv_len
                rows.append(row)

            return GqldbTable(columns=columns, rows=rows, column_types=column_types)
        except Exception:
            return GqldbTable()

    def _decode_string_from(self, data: bytes, offset: int) -> tuple:
        """Decode a length-prefixed string: [len:2][utf8 bytes]. Returns (string, bytes consumed)."""
        if offset + 2 > len(data):
            return "", 0
        length = struct.unpack("<H", data[offset:offset+2])[0]
        if offset + 2 + length > len(data):
            return "", 0
        return data[offset+2:offset+2+length].decode("utf-8"), 2 + length

    def _decode_tve_from(self, data: bytes, offset: int) -> tuple:
        """Decode a TypedValueEntry: [type:1][isNull:1][dataLen:4][data]. Returns (TypedValue, bytes consumed)."""
        if offset + 6 > len(data):
            return TypedValue(type=PropertyType.NULL, data=b"", is_null=True), 0
        tv_type = PropertyType(data[offset])
        is_null = data[offset+1] != 0
        data_len = struct.unpack("<I", data[offset+2:offset+6])[0]
        if offset + 6 + data_len > len(data):
            return TypedValue(type=PropertyType.NULL, data=b"", is_null=True), 0
        tv_data = data[offset+6:offset+6+data_len]
        return TypedValue(type=tv_type, data=tv_data, is_null=is_null), 6 + data_len

    def _decode_properties_binary(self, data: bytes, offset: int) -> Dict[str, Any]:
        """Decode binary properties: [count:2][keyLen:2][key][TVE]..."""
        result = {}
        if offset + 2 > len(data):
            return result
        count = struct.unpack("<H", data[offset:offset+2])[0]
        pos = offset + 2

        for _ in range(count):
            key, key_len = self._decode_string_from(data, pos)
            if key_len == 0:
                break
            pos += key_len

            tv, tv_len = self._decode_tve_from(data, pos)
            if tv_len == 0:
                break
            pos += tv_len

            result[key] = tv.to_python()

        return result

    @classmethod
    def _encode_list(cls, values: List[Any]) -> "TypedValue":
        """Encode a list to bytes. Binary format: [count:2][TVE]..."""
        data = bytearray()
        # Write count (2 bytes)
        data.extend(struct.pack("<H", len(values)))

        for v in values:
            tv = cls.from_value(v)
            # Write TypedValueEntry: [type:1][isNull:1][dataLen:4][data]
            data.append(tv.type.value)
            data.append(1 if tv.is_null else 0)
            data.extend(struct.pack("<I", len(tv.data)))
            data.extend(tv.data)

        return cls(type=PropertyType.LIST, data=bytes(data))

    def _decode_list(self) -> List[Any]:
        """Decode a list from bytes.

        Supports both JSON format (e.g., "[1,2,3]") and binary format.
        Binary format: [count:2][TVE]... where TVE is [type:1][isNull:1][dataLen:4][data]
        """
        if len(self.data) == 0:
            return []

        # Check if data is JSON format (starts with '[')
        if self.data[0:1] == b'[':
            try:
                return json.loads(self.data.decode('utf-8'))
            except (json.JSONDecodeError, UnicodeDecodeError):
                return []

        # Binary format: [count:2][TVE]...
        if len(self.data) < 2:
            return []

        count = struct.unpack("<H", self.data[:2])[0]
        offset = 2
        result = []

        for _ in range(count):
            tv, tv_len = self._decode_tve_from(self.data, offset)
            if tv_len == 0:
                break
            offset += tv_len
            result.append(tv.to_python())

        return result

    @classmethod
    def _encode_map(cls, m: Dict[str, Any]) -> "TypedValue":
        """Encode a map to bytes. Binary format: [count:2][keyLen:2][key][TVE]..."""
        data = bytearray()
        # Write count (2 bytes)
        data.extend(struct.pack("<H", len(m)))

        for k, v in m.items():
            # Write key (2-byte length prefix)
            key_bytes = k.encode("utf-8")
            data.extend(struct.pack("<H", len(key_bytes)))
            data.extend(key_bytes)

            # Write value as TVE
            tv = cls.from_value(v)
            data.append(tv.type.value)
            data.append(1 if tv.is_null else 0)
            data.extend(struct.pack("<I", len(tv.data)))
            data.extend(tv.data)

        return cls(type=PropertyType.MAP, data=bytes(data))

    @classmethod
    def _encode_set(cls, values: set) -> "TypedValue":
        """Encode a set to bytes. Binary format: [count:2][TVE]..."""
        data = bytearray()
        # Write count (2 bytes)
        data.extend(struct.pack("<H", len(values)))

        for v in values:
            tv = cls.from_value(v)
            # Write TypedValueEntry
            data.append(tv.type.value)
            data.append(1 if tv.is_null else 0)
            data.extend(struct.pack("<I", len(tv.data)))
            data.extend(tv.data)

        return cls(type=PropertyType.SET, data=bytes(data))

    def _decode_map(self) -> Dict[str, Any]:
        """Decode a map from bytes. Binary format: [count:2][keyLen:2][key][TVE]..."""
        if len(self.data) < 2:
            return {}

        count = struct.unpack("<H", self.data[:2])[0]
        offset = 2
        result = {}

        for _ in range(count):
            # Read key
            key, key_len = self._decode_string_from(self.data, offset)
            if key_len == 0:
                break
            offset += key_len

            # Read value as TVE
            tv, tv_len = self._decode_tve_from(self.data, offset)
            if tv_len == 0:
                break
            offset += tv_len

            result[key] = tv.to_python()

        return result



@dataclass
class Parameter:
    """Named parameter for queries."""
    name: str
    value: TypedValue

    @classmethod
    def create(cls, name: str, value: Any) -> "Parameter":
        """Create a parameter from name and Python value."""
        return cls(name=name, value=TypedValue.from_value(value))


# ---------------------------------------------------------------------------
# Module-level helper functions for format_value / from_string
# ---------------------------------------------------------------------------

def _format_nanos(nanos: int) -> str:
    """Format nanoseconds as a fractional string with trailing zeros trimmed.
    Returns empty string when nanos == 0."""
    if nanos == 0:
        return ""
    s = f"{nanos:09d}"
    return "." + s.rstrip("0")


def _format_time_component(hour: int, minute: int, second: int, nanos: int) -> str:
    """Format HH:mm:ss[.nnnnnnnnn]."""
    return f"{hour:02d}:{minute:02d}:{second:02d}{_format_nanos(nanos)}"


def _format_offset(offset_minutes: int) -> str:
    """Format offset in minutes as +HH:MM or -HH:MM."""
    if offset_minutes < 0:
        sign = "-"
        total = -offset_minutes
    else:
        sign = "+"
        total = offset_minutes
    h = total // 60
    m = total % 60
    return f"{sign}{h:02d}:{m:02d}"


def _parse_time_string(s: str) -> tuple:
    """Parse HH:mm:ss[.nnnnnnnnn] and return (hour, minute, second, nanos)."""
    dot_idx = s.find(".")
    if dot_idx >= 0:
        hms = s[:dot_idx]
        frac_str = s[dot_idx + 1:]
        # Pad to 9 digits
        frac_str = frac_str.ljust(9, "0")[:9]
        nanos = int(frac_str)
    else:
        hms = s
        nanos = 0
    parts = hms.split(":")
    return int(parts[0]), int(parts[1]), int(parts[2]), nanos


def _remove_offset(s: str) -> str:
    """Strip trailing Z or timezone offset (with optional space) from a time/datetime string.
    Returns the clean local time/datetime string.
    Only inspects the last ~10 characters to avoid matching date hyphens like '1993-05-06'."""
    # Handle trailing Z
    if s.endswith("Z"):
        return s[:-1]
    # Look for offset pattern in the tail of the string
    tail = s[-10:] if len(s) > 10 else s
    m = re.search(r'\s?[+-]\d{2}:\d{2}$', tail)
    if m:
        # Compute position in the full string
        cut = len(s) - (len(tail) - m.start())
        return s[:cut]
    return s


def _normalize_offset(s: str) -> str:
    """Collapse optional space before offset and convert trailing Z to +00:00.
    E.g. '14:30:00 +08:00' -> '14:30:00+08:00', '14:30:00Z' -> '14:30:00+00:00'."""
    if s.endswith("Z"):
        return s[:-1] + "+00:00"
    # Look for space before offset in the tail
    tail = s[-10:] if len(s) > 10 else s
    m = re.search(r'\s([+-]\d{2}:\d{2})$', tail)
    if m:
        cut = len(s) - (len(tail) - m.start())
        return s[:cut] + m.group(1)
    return s


def _split_time_and_offset(s: str) -> tuple:
    """Split a time string with offset like '09:11:02-08:00' or '09:11:02+05:30'.
    Also supports 'Z' suffix and space-separated offsets.
    Returns (time_part, offset_minutes)."""
    # Normalize: collapse space before offset, convert Z to +00:00
    s = _normalize_offset(s)
    # Find the last +/- that indicates offset (skip the first character to handle negative)
    # Offset pattern: [+-]HH:MM at the end
    m = re.search(r'([+-])(\d{2}):(\d{2})$', s)
    if m:
        time_part = s[:m.start()]
        sign = 1 if m.group(1) == "+" else -1
        oh = int(m.group(2))
        om = int(m.group(3))
        offset_minutes = sign * (oh * 60 + om)
        return time_part, offset_minutes
    return s, 0


def _parse_year_to_month(s: str) -> int:
    """Parse ISO-8601 year-to-month duration like 'P2Y5M', 'P0M', '-P1Y5M'."""
    neg = s.startswith("-")
    p = s.lstrip("-")
    if not p.startswith("P"):
        raise ValueError(f"Invalid YEAR_TO_MONTH format: {s}")
    p = p[1:]  # Remove 'P'
    years = 0
    months_part = 0
    ym = re.match(r'(\d+)Y', p)
    if ym:
        years = int(ym.group(1))
        p = p[ym.end():]
    mm = re.match(r'(\d+)M', p)
    if mm:
        months_part = int(mm.group(1))
    total = years * 12 + months_part
    return -total if neg else total


def _parse_day_to_second(s: str) -> tuple:
    """Parse ISO-8601 day-to-second duration like 'P3DT4H', 'PT0S', 'P1DT2H3M4.12S'.
    Returns (total_seconds, nanoseconds)."""
    neg = s.startswith("-")
    p = s.lstrip("-")
    if not p.startswith("P"):
        raise ValueError(f"Invalid DAY_TO_SECOND format: {s}")
    p = p[1:]  # Remove 'P'

    days = 0
    dm = re.match(r'(\d+)D', p)
    if dm:
        days = int(dm.group(1))
        p = p[dm.end():]

    hours = 0
    minutes = 0
    secs = 0
    nanos = 0

    if p.startswith("T"):
        p = p[1:]
        hm = re.match(r'(\d+)H', p)
        if hm:
            hours = int(hm.group(1))
            p = p[hm.end():]
        mm = re.match(r'(\d+)M', p)
        if mm:
            minutes = int(mm.group(1))
            p = p[mm.end():]
        sm = re.match(r'(\d+(?:\.\d+)?)S', p)
        if sm:
            sec_str = sm.group(1)
            if "." in sec_str:
                whole, frac = sec_str.split(".")
                secs = int(whole)
                frac = frac.ljust(9, "0")[:9]
                nanos = int(frac)
            else:
                secs = int(sec_str)

    total_secs = days * 86400 + hours * 3600 + minutes * 60 + secs
    if neg:
        total_secs = -total_secs
        # nanos stays positive per the DayToSecond encoding
    return total_secs, nanos


def _format_day_to_second(seconds: int, nanoseconds: int) -> str:
    """Format DayToSecond as ISO-8601 duration."""
    if seconds == 0 and nanoseconds == 0:
        return "PT0S"

    neg = seconds < 0
    abs_secs = abs(seconds)

    days = abs_secs // 86400
    rem = abs_secs % 86400
    hours = rem // 3600
    rem = rem % 3600
    mins = rem // 60
    secs = rem % 60

    result = "-P" if neg else "P"
    if days > 0:
        result += f"{days}D"

    has_time = hours > 0 or mins > 0 or secs > 0 or nanoseconds > 0
    if has_time:
        result += "T"
        if hours > 0:
            result += f"{hours}H"
        if mins > 0:
            result += f"{mins}M"
        if secs > 0 or nanoseconds > 0:
            if nanoseconds > 0:
                frac = f"{nanoseconds:09d}".rstrip("0")
                result += f"{secs}.{frac}S"
            else:
                result += f"{secs}S"

    return result
