"""Tenant-local time conversion — the only sanctioned place for timezone logic.

Everything in the database is stored in UTC (every datetime column is
TIMESTAMPTZ), which is correct and stays that way. The problem this module
solves is the *other* class of value: wall-clock dates and times that have no
zone of their own.

A scheduled service is stored as `service_date DATE` + `service_time TIME`.
"2:00 PM" is a fact about the cemetery, not about UTC — so combining those two
columns into an instant requires knowing where the cemetery is. Getting that
wrong is not cosmetic: the combined instant drives ARQ's `_defer_until` for the
24-hour family reminder, so a UTC assumption sends the reminder 4-5 hours off,
and the size of the error changes across a DST boundary.

Product rule: times anchor to the CEMETERY's zone, not the viewer's. A 2:00 PM
Toronto service reads "2:00 PM" for a family member in India, because the event
physically happens at a place. That is the opposite of the browser's default
behaviour, which is why render sites must pass an explicit zone rather than
letting `toLocaleString`/`strftime` fall through to the local default.

Do not call `datetime.combine()` or `date.today()` in services — both silently
depend on the server's zone. Use `local_datetime()` and `tenant_today()`.
"""

from __future__ import annotations

from datetime import date as date_type
from datetime import datetime, time as time_type, timezone
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

DEFAULT_TIMEZONE = "America/Toronto"

# ZoneInfo already caches internally, but resolving a bad name raises — and these
# are called per-row in list endpoints, so failures must be cheap and total.
_UTC = timezone.utc


def resolve_tz(name: Optional[str]) -> ZoneInfo:
    """IANA name -> ZoneInfo, falling back to the platform default.

    Fails soft: a tenant with a missing or unrecognised zone renders in the
    default rather than 500ing a whole listing page. Validation belongs at the
    write boundary (the settings schema), not here.
    """
    try:
        return ZoneInfo(name or DEFAULT_TIMEZONE)
    except (ZoneInfoNotFoundError, ValueError):
        return ZoneInfo(DEFAULT_TIMEZONE)


def tenant_tz(account) -> ZoneInfo:
    """The cemetery's zone for an Account (or anything exposing `.timezone`)."""
    return resolve_tz(getattr(account, "timezone", None))


def local_datetime(
    day: Optional[date_type],
    clock: Optional[time_type],
    tz: ZoneInfo,
) -> Optional[datetime]:
    """Combine a wall-clock date + time in `tz` into an aware UTC instant.

    This is the correct replacement for `datetime.combine(d, t, tzinfo=utc)`,
    which treats local wall-clock as though it were already UTC.

    A missing time means midnight local. A missing date means there is no
    instant to compute, so callers get None rather than a fabricated one.

    Note on DST: a wall-clock time can be ambiguous (the repeated hour when
    clocks go back) or non-existent (the skipped hour when they go forward).
    ZoneInfo resolves both deterministically — ambiguous times take the first
    (pre-transition) offset via fold=0. That is the desired reading for a
    scheduled service: the earlier of the two 1:30 AMs.
    """
    if day is None:
        return None
    naive = datetime.combine(day, clock or time_type(0, 0))
    return naive.replace(tzinfo=tz).astimezone(_UTC)


def to_tenant_local(value: Optional[datetime], tz: ZoneInfo) -> Optional[datetime]:
    """Render a stored UTC instant in the cemetery's zone.

    Use this before any `strftime` in a PDF, export, or email — those all
    format whatever offset the datetime carries, so formatting a raw UTC value
    prints UTC while claiming to be the service time.

    A naive input is assumed UTC, matching how such values reach us from the
    database, rather than being silently relabelled as local.
    """
    if value is None:
        return None
    if value.tzinfo is None:
        value = value.replace(tzinfo=_UTC)
    return value.astimezone(tz)


def tenant_today(tz: ZoneInfo) -> date_type:
    """"Today" at the cemetery.

    Replaces `date.today()`, which reads the server process's zone — an
    implicit dependency on container config that puts the date up to a full day
    off for the tenant. This matters well beyond display: due-date comparisons
    decide how much a family is charged online and when an invoice becomes
    overdue.
    """
    return datetime.now(_UTC).astimezone(tz).date()


def tz_abbreviation(value: datetime, tz: ZoneInfo) -> str:
    """Short zone label ("EDT", "IST") for stamping onto rendered times.

    Takes the instant because the abbreviation is not a property of the zone
    alone — America/Toronto is EST or EDT depending on the date.
    """
    localized = to_tenant_local(value, tz)
    return localized.strftime("%Z") if localized else ""
