"""Timezone helpers for the reservation domain.

Storage convention
------------------
Every ``TIMESTAMP WITHOUT TIME ZONE`` column stores **naive-UTC** — the
wall-clock instant in UTC with ``tzinfo`` stripped. This matches
``app.db.base.utcnow()`` and makes cross-restaurant queries trivial:
sorting, range comparisons, and aggregations all work without per-row
timezone arithmetic.

Restaurants think in local time though. The helpers in this module
convert at the boundary, never in the middle:

- ``parse_local_iso`` / ``to_naive_utc``: accept an aware or naive
  datetime from the wire and produce a naive-UTC value for storage.
  Naive input is interpreted as restaurant-local time — the safest
  default because every customer-facing channel (widget, agent,
  dashboard) sends restaurant-local times.
- ``naive_utc_to_local``: convert a stored value back to naive
  restaurant-local for time-of-day logic (service blocks, opening
  hours).
- ``local_day_window``: build a half-open ``[start, end)`` naive-UTC
  range that covers exactly one restaurant-local calendar day —
  required for ``Reservation.reserved_at`` filters.

``UtcDatetime`` is an ``Annotated[datetime, ...]`` type whose JSON
serializer emits an explicit ``Z`` suffix so frontend ``new Date(...)``
calls parse stored values as UTC instead of as browser-local time.
"""

from __future__ import annotations

from datetime import UTC, date, datetime, timedelta
from typing import Annotated
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from pydantic import PlainSerializer

DEFAULT_TZ = "Europe/Brussels"


def resolve_tz(name: str | None) -> ZoneInfo:
    """Resolve an IANA timezone name, falling back to ``DEFAULT_TZ``.

    Empty/whitespace input and unknown zone names both yield the
    default rather than raising — every caller is on a hot path where
    raising would surface as a 500 on otherwise-valid data.
    """
    if not isinstance(name, str):
        return ZoneInfo(DEFAULT_TZ)
    cleaned = name.strip() or DEFAULT_TZ
    try:
        return ZoneInfo(cleaned)
    except (ZoneInfoNotFoundError, KeyError):
        return ZoneInfo(DEFAULT_TZ)


def to_naive_utc(value: datetime, tz: ZoneInfo) -> datetime:
    """Normalise a datetime to naive-UTC for storage.

    Aware input is converted to UTC. Naive input is interpreted as
    restaurant-local time (in ``tz``) and then converted to UTC. In
    both cases the returned value has ``tzinfo`` stripped.
    """
    if value.tzinfo is None:
        value = value.replace(tzinfo=tz)
    return value.astimezone(UTC).replace(tzinfo=None)


def parse_local_iso(iso: str, tz: ZoneInfo) -> datetime:
    """Parse an ISO 8601 string and normalise to naive-UTC.

    Mirrors :func:`to_naive_utc` — naive ISO inputs are treated as
    restaurant-local time. Raises ``ValueError`` on malformed input.
    """
    return to_naive_utc(datetime.fromisoformat(iso), tz)


def naive_utc_to_local(value: datetime, tz: ZoneInfo) -> datetime:
    """Convert a naive-UTC stored value to naive restaurant-local time.

    Use this whenever you need to compare against service-block
    times-of-day, render an hour for a prompt, or extract the
    restaurant-local calendar date.
    """
    return value.replace(tzinfo=UTC).astimezone(tz).replace(tzinfo=None)


def local_day_window(day: date, tz: ZoneInfo) -> tuple[datetime, datetime]:
    """Return naive-UTC ``[start, end)`` for the restaurant-local day.

    The endpoints exactly span one local calendar day, so DST
    transitions yield 23- or 25-hour windows. Use the result with
    ``reserved_at >= start AND reserved_at < end`` against the naive-UTC
    column.
    """
    local_start = datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=tz)
    local_end = local_start + timedelta(days=1)
    return (
        local_start.astimezone(UTC).replace(tzinfo=None),
        local_end.astimezone(UTC).replace(tzinfo=None),
    )


def local_now(tz: ZoneInfo) -> datetime:
    """Restaurant-local naive datetime for the current instant."""
    return datetime.now(UTC).astimezone(tz).replace(tzinfo=None)


def _serialize_utc(value: datetime | None) -> str | None:
    """Pydantic serializer: emit naive-UTC datetimes with an explicit ``Z``.

    Naive input is assumed to follow the storage convention (already
    naive-UTC). Aware input is converted to UTC first. Output always
    has the ``Z`` suffix so frontend ``new Date(...)`` parses it as UTC
    instead of falling back to browser-local interpretation.
    """
    if value is None:
        return None
    utc = value if value.tzinfo is None else value.astimezone(UTC).replace(tzinfo=None)
    base = utc.strftime("%Y-%m-%dT%H:%M:%S")
    if utc.microsecond:
        return f"{base}.{utc.microsecond:06d}Z"
    return f"{base}Z"


UtcDatetime = Annotated[
    datetime,
    PlainSerializer(_serialize_utc, return_type=str, when_used="json"),
]
"""Pydantic type for datetime fields stored as naive-UTC.

Use on every API response schema field that carries a stored
``TIMESTAMP WITHOUT TIME ZONE`` value. The wire format ends with ``Z``
so frontend code can parse it as UTC unambiguously.
"""
