"""Pure validation and state machine for atcwatch.

This module deliberately has no network, filesystem, subprocess, or Qt
imports.  Every transition receives an explicit timezone-aware ``now``.
"""

from __future__ import annotations

from copy import deepcopy
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import re
from typing import Any, Iterable, Mapping, NamedTuple


TS_FORMAT = "%Y-%m-%dT%H:%MZ"
ICAO_RE = re.compile(r"^[A-Z0-9]{4}$")
POSITION_ORDER = ("DEL", "GND", "TWR", "DEP", "APP", "CTR")
POSITION_KEYS = {position.lower() for position in POSITION_ORDER}
RADAR_POSITIONS = frozenset({"APP", "DEP", "CTR"})
POSITION_STATES = frozenset({
    "fixed", "online_h1", "online_h2", "online_h3", "none",
    "fixed_derived", "online_h1_derived", "online_h2_derived",
    "online_h3_derived", "none_derived",
})
STATUS_VALUES = frozenset({"live", "upcoming", "none", "unknown"})
DOMAIN_KINDS = frozenset({"live", "lead", "upgrade", "cancelled", "event"})

EVENT_NOTIFY_HORIZON_DAYS = 30
CANCEL_RESCHEDULE_WINDOW_MIN = 60
LEAD_BUCKET_MIN = 15
LEAD_GUARD_HOURS = 6
HEALTH_WARNING_MIN = 60
MAX_FIRED = 2000

# More than this many pending domain events in one delivery cycle are sent as
# one digest notification instead of a popup salvo (plan 2.4/6.2).
DIGEST_THRESHOLD = 2
# The airport block of the tray menu shows at most this many rows (plan 5.2).
TRAY_MENU_LIMIT = 5
ICON_STATES = ("idle", "live", "upcoming", "error")


DEFAULTS: dict[str, Any] = {
    # The server refreshes the VATSIM picture every 5 minutes (booking-circle
    # cron).  Asking more often just returns the same snapshot; asking less
    # often wastes latency.  This value tracks the server's cadence — it is
    # not a free choice.
    "poll_min": 5,
    "notify_lead_min": 45,
    "notify_upgrades": True,
    "timeout_sec": 15,
    "stale_max_min": 20,
    "offline_cooldown_min": 60,
    # Which notifications stay on screen until the user dismisses them.
    # Entries are a kind out of DOMAIN_KINDS, or "upgrade:<POS>" to single out
    # one radar position.  Deliberately a list and not one flag per kind: it
    # carries both axes (kind and position) and does not grow a new switch with
    # every new kind.  The operational kinds (health, recovery) are not
    # configurable — a blind tool is a silent failure and always sticks.
    #
    # APP and CTR, but not DEP: approach service is what a pilot plans around,
    # and a CTR going online brings approach with it top-down (its position
    # state derives app from ctr).  DEP is the mirror image for departures and
    # just as defensible — it stays out because the airports here are watched
    # as destinations.  Adding "upgrade:DEP" needs no code change.
    "sticky_notifications": ["live", "lead", "upgrade:APP", "upgrade:CTR"],
    # The command the tray runs for "To the Sim", as argv — program first, then
    # its arguments.  A list and not a string: no shell, no quoting rules, and
    # nothing of the user's own path lands in atcwatch.  Empty means the action
    # is not configured, which is the default for everyone who never applies a
    # flight locally.
    "flight_apply_cmd": [],
}


class PayloadValidationError(ValueError):
    """The response is not a complete trustworthy API snapshot."""


PayloadError = PayloadValidationError


@dataclass(frozen=True)
class Slot:
    start: datetime
    end: datetime
    positions: tuple[str, ...]
    booking_positions: tuple[str, ...]


@dataclass(frozen=True)
class CanonicalEvent:
    identity: str
    start: datetime
    end: datetime
    name: str
    link: str


@dataclass(frozen=True)
class AirportSnapshot:
    icao: str
    name: str
    status: str
    score: float | None
    online_positions: tuple[str, ...]
    slots: tuple[Slot, ...]
    events: tuple[CanonicalEvent, ...]
    next_start: datetime | None
    next_end: datetime | None
    ground: int | None = None

    @property
    def live(self) -> bool:
        return bool(self.online_positions)


@dataclass(frozen=True)
class Snapshot:
    generated_at: datetime
    vatsim_generated_at: datetime | None
    window_start: datetime
    window_end: datetime
    airports: Mapping[str, AirportSnapshot]
    events: tuple[CanonicalEvent, ...]


@dataclass(frozen=True)
class Notification:
    id: str
    key: str
    kind: str
    icao: str | None
    title: str
    body: str
    urgent: bool
    created_at: datetime
    data: Mapping[str, Any]

    @classmethod
    def from_record(cls, value: Mapping[str, Any]) -> "Notification":
        return cls(
            id=str(value["id"]),
            key=str(value["key"]),
            kind=str(value["kind"]),
            icao=value.get("icao") if isinstance(value.get("icao"), str) else None,
            title=str(value.get("title", "atcwatch")),
            body=str(value.get("body", "")),
            urgent=bool(value.get("urgent")),
            created_at=parse_ts(value["created_at"]),
            data=deepcopy(value.get("data", {})),
        )


@dataclass(frozen=True)
class Decision:
    state: dict[str, Any]
    notifications: tuple[Notification, ...]
    summary: Mapping[str, Any]
    log: tuple[str, ...] = ()


def parse_ts(value: Any) -> datetime:
    if not isinstance(value, str):
        raise PayloadValidationError(f"timestamp must be a string, got {type(value).__name__}")
    try:
        return datetime.strptime(value, TS_FORMAT).replace(tzinfo=timezone.utc)
    except ValueError as exc:
        raise PayloadValidationError(
            f"invalid UTC timestamp {value!r}; expected YYYY-MM-DDTHH:MMZ"
        ) from exc


def try_parse_ts(value: Any) -> datetime | None:
    try:
        return parse_ts(value)
    except PayloadValidationError:
        return None


def format_ts(value: datetime) -> str:
    if value.tzinfo is None:
        raise ValueError("naive datetime is not allowed")
    return value.astimezone(timezone.utc).replace(second=0, microsecond=0).strftime(TS_FORMAT)


fmt_ts = format_ts


def floor_bucket(value: datetime) -> datetime:
    minute = value.minute - value.minute % LEAD_BUCKET_MIN
    return value.replace(minute=minute, second=0, microsecond=0)


def _require_dict(value: Any, path: str) -> dict[str, Any]:
    if not isinstance(value, dict):
        raise PayloadValidationError(f"{path} must be an object")
    return value


def _require_list(value: Any, path: str) -> list[Any]:
    if not isinstance(value, list):
        raise PayloadValidationError(f"{path} must be an array")
    return value


def _positions(value: Any, path: str) -> tuple[str, ...]:
    raw = _require_list(value, path)
    if any(not isinstance(item, str) or item not in POSITION_ORDER for item in raw):
        raise PayloadValidationError(f"{path} contains an unsupported position")
    if len(set(raw)) != len(raw):
        raise PayloadValidationError(f"{path} contains duplicate positions")
    return tuple(position for position in POSITION_ORDER if position in raw)


def _validate_slot(value: Any, path: str) -> Slot:
    slot = _require_dict(value, path)
    start = parse_ts(slot.get("start"))
    end = parse_ts(slot.get("end"))
    if end <= start:
        raise PayloadValidationError(f"{path}.end must be after start")
    raw_positions = _require_list(slot.get("positions"), f"{path}.positions")
    positions: list[str] = []
    booking_positions: list[str] = []
    for index, raw in enumerate(raw_positions):
        position = _require_dict(raw, f"{path}.positions[{index}]")
        pos = position.get("pos")
        state = position.get("state")
        if pos not in POSITION_ORDER:
            raise PayloadValidationError(f"{path}.positions[{index}].pos is unsupported")
        if state not in POSITION_STATES:
            raise PayloadValidationError(f"{path}.positions[{index}].state is unsupported")
        if pos in positions:
            raise PayloadValidationError(f"{path}.positions contains duplicate {pos}")
        positions.append(pos)
        if state == "fixed":
            booking_positions.append(pos)
    return Slot(
        start=start,
        end=end,
        positions=tuple(pos for pos in POSITION_ORDER if pos in positions),
        booking_positions=tuple(pos for pos in POSITION_ORDER if pos in booking_positions),
    )


def _fallback_event_identity(
    icao: str, start: datetime, end: datetime, name: str, link: str,
) -> str:
    normalized_name = " ".join(name.casefold().split())
    material = "\0".join((
        icao, format_ts(start), format_ts(end), normalized_name, link.strip(),
    )).encode("utf-8")
    return "fp-" + hashlib.sha256(material).hexdigest()[:24]


def _validate_event(value: Any, path: str, icao: str) -> CanonicalEvent:
    event = _require_dict(value, path)
    start = parse_ts(event.get("start"))
    end = parse_ts(event.get("end"))
    if end <= start:
        raise PayloadValidationError(f"{path}.end must be after start")
    name = event.get("name")
    # The contract (PLAN A2) promises a nullable link; null and "" both mean
    # "no link".
    link = event.get("link", "")
    if link is None:
        link = ""
    if not isinstance(name, str) or not name.strip() or len(name) > 500:
        raise PayloadValidationError(f"{path}.name must be a non-empty string")
    if not isinstance(link, str) or len(link) > 2000:
        raise PayloadValidationError(f"{path}.link must be a string or null")
    event_id = event.get("event_id", event.get("id"))
    if event_id is not None and (not isinstance(event_id, str) or not event_id.strip()):
        raise PayloadValidationError(f"{path}.event_id must be a non-empty string")
    identity = event_id.strip() if isinstance(event_id, str) else _fallback_event_identity(
        icao, start, end, name, link)
    return CanonicalEvent(identity, start, end, name.strip(), link)


def _validate_airport(value: Any, path: str, expected_icao: str) -> AirportSnapshot:
    airport = _require_dict(value, path)
    icao = airport.get("icao")
    status = airport.get("status")
    if icao != expected_icao:
        raise PayloadValidationError(
            f"{path}.icao is {icao!r}, expected {expected_icao!r} in request order")
    if status not in STATUS_VALUES:
        raise PayloadValidationError(f"{path}.status is unsupported")

    if status == "unknown":
        extras = set(airport) - {"icao", "status"}
        if extras:
            raise PayloadValidationError(
                f"{path} unknown airport has unsupported fields {sorted(extras)}")
        return AirportSnapshot(icao, "", status, None, (), (), (), None, None)

    ground: int | None = None
    if "vatsim_ground" in airport:
        ground = airport["vatsim_ground"]
        if isinstance(ground, bool) or not isinstance(ground, int) or ground < 0:
            raise PayloadValidationError(f"{path}.vatsim_ground must be an integer >= 0")

    online = _positions(
        airport.get("current_online_positions"),
        f"{path}.current_online_positions",
    )
    raw_events = _require_list(airport.get("events"), f"{path}.events")
    events = tuple(
        _validate_event(event, f"{path}.events[{index}]", icao)
        for index, event in enumerate(raw_events)
    )
    identities = [item.identity for item in events]
    if len(set(identities)) != len(identities):
        raise PayloadValidationError(f"{path}.events contains duplicate identities")
    if list(events) != sorted(events, key=lambda item: (item.start, item.end, item.identity)):
        raise PayloadValidationError(f"{path}.events must be sorted by start")

    forbidden_when_empty = {
        "score", "position_states", "slot_times", "slot_groups",
        "next_start", "next_end",
    }
    if status == "none":
        present = forbidden_when_empty.intersection(airport)
        if present:
            raise PayloadValidationError(
                f"{path} status 'none' must omit {sorted(present)}")
        if online:
            raise PayloadValidationError(f"{path} none airport cannot have online positions")
        name = airport.get("name")
        if not isinstance(name, str) or not name:
            raise PayloadValidationError(f"{path}.name is required")
        return AirportSnapshot(icao, name, status, None, (), (), events, None, None, ground)

    name = airport.get("name")
    score = airport.get("score")
    if not isinstance(name, str) or not name:
        raise PayloadValidationError(f"{path}.name is required")
    if isinstance(score, bool) or not isinstance(score, (int, float)) or not 0 <= score <= 5:
        raise PayloadValidationError(f"{path}.score must be a number from 0 to 5")
    states = _require_dict(airport.get("position_states"), f"{path}.position_states")
    if set(states) != POSITION_KEYS or any(value not in POSITION_STATES for value in states.values()):
        raise PayloadValidationError(f"{path}.position_states violates the position contract")
    _require_list(airport.get("slot_groups"), f"{path}.slot_groups")
    raw_slots = _require_list(airport.get("slot_times"), f"{path}.slot_times")
    if not raw_slots:
        raise PayloadValidationError(f"{path}.slot_times must not be empty")
    slots = tuple(
        _validate_slot(slot, f"{path}.slot_times[{index}]")
        for index, slot in enumerate(raw_slots)
    )
    if list(slots) != sorted(slots, key=lambda item: (item.start, item.end)):
        raise PayloadValidationError(f"{path}.slot_times must be sorted by start")
    next_start = parse_ts(airport.get("next_start"))
    next_end = parse_ts(airport.get("next_end"))
    if next_end <= next_start:
        raise PayloadValidationError(f"{path}.next_end must be after next_start")
    if status == "upcoming" and online:
        raise PayloadValidationError(f"{path} upcoming airport cannot have online positions")
    return AirportSnapshot(
        icao, name, status, float(score), online, slots, events, next_start, next_end,
        ground)


def validate_payload(payload: Any, requested_icaos: Iterable[str]) -> Snapshot:
    """Strictly validate the complete response before any domain mutation."""

    root = _require_dict(payload, "payload")
    requested = tuple(requested_icaos)
    if not requested or any(not ICAO_RE.fullmatch(icao) for icao in requested):
        raise PayloadValidationError("requested ICAOs are invalid")
    if len(set(requested)) != len(requested):
        raise PayloadValidationError("requested ICAOs contain duplicates")
    for availability in ("airport_data_available", "events_available"):
        if availability in root and root[availability] is not True:
            raise PayloadValidationError(f"payload reports {availability}=false")
    horizon = root.get("event_horizon_days")
    if isinstance(horizon, bool) or horizon != EVENT_NOTIFY_HORIZON_DAYS:
        raise PayloadValidationError(
            "payload.event_horizon_days must confirm the requested "
            f"{EVENT_NOTIFY_HORIZON_DAYS} days, got {horizon!r}")
    generated_at = parse_ts(root.get("generated_at"))
    raw_vatsim = root.get("vatsim_generated_at")
    vatsim_generated_at = None if raw_vatsim is None else parse_ts(raw_vatsim)
    window = _require_dict(root.get("window"), "payload.window")
    window_start = parse_ts(window.get("from"))
    window_end = parse_ts(window.get("to"))
    if window_end <= window_start:
        raise PayloadValidationError("payload.window.to must be after from")
    raw_airports = _require_list(root.get("airports"), "payload.airports")
    if len(raw_airports) != len(requested):
        raise PayloadValidationError(
            "payload must contain every requested ICAO exactly once and no extras")
    airports: dict[str, AirportSnapshot] = {}
    for index, expected in enumerate(requested):
        airport = _validate_airport(raw_airports[index], f"payload.airports[{index}]", expected)
        if airport.icao in airports:
            raise PayloadValidationError(f"duplicate airport {airport.icao}")
        airports[airport.icao] = airport

    all_events = tuple(
        event for airport in airports.values() for event in airport.events)
    return Snapshot(
        generated_at, vatsim_generated_at, window_start, window_end,
        airports, all_events)


def freshness(snapshot: Snapshot, now: datetime, max_age_min: int) -> tuple[bool, str]:
    """Classify VATSIM freshness without changing event state."""

    if now.tzinfo is None:
        raise ValueError("now must be timezone-aware")
    stamp = snapshot.vatsim_generated_at
    if stamp is None:
        return False, "vatsim_generated_at is null"
    if stamp > now + timedelta(minutes=5):
        return False, "vatsim_generated_at is in the future"
    age = now - stamp
    if age > timedelta(minutes=max_age_min):
        return False, f"VATSIM data is {int(age.total_seconds() // 60)} minutes old"
    return True, ""


def _notification_id(key: str) -> str:
    return hashlib.sha256(key.encode("utf-8")).hexdigest()[:24]


def _event_key(icao: str, identity: str) -> str:
    digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24]
    return f"{icao}|event|{digest}"


def _is_paused(state: Mapping[str, Any], now: datetime) -> bool:
    paused_until = try_parse_ts(state.get("paused_until"))
    return paused_until is not None and paused_until > now


def deliveries_held(state: Mapping[str, Any]) -> bool:
    """True while the session is away and the outbox must not be delivered."""

    return try_parse_ts(state.get("away_since")) is not None


def apply_away(
    state: Mapping[str, Any], away: bool | None, now: datetime,
) -> tuple[dict[str, Any], timedelta | None]:
    """Fold one observed availability reading into the away tracker.

    ``away`` answers a single question — can the user see and dismiss a popup
    right now? — no matter which source answered it (locked screen, inhibited
    notifications, …).  There is deliberately one field and one edge: two
    reasons that overlap must not produce two away periods.

    ``away`` is ``None`` when no source can be asked at all (no DBus, another
    desktop, macOS).  The feature is then silently off: the reading counts as
    "present", so nothing is ever held back.

    Returns the new state and, on the away→back edge only, how long the
    session was away — the caller then flushes the outbox as one digest.
    """

    result = deepcopy(state)
    away_since = try_parse_ts(result.get("away_since"))
    if away:
        if away_since is None:
            result["away_since"] = format_ts(now)
        return result, None
    if away_since is None:
        return result, None
    result["away_since"] = None
    # A backwards wall-clock jump must not produce a negative duration.
    return result, max(timedelta(0), now - away_since)


def format_away(delta: timedelta) -> str:
    """Human-readable absence length, in the tray's "1 h 20 min" style."""

    minutes = max(0, int(delta.total_seconds() // 60))
    hours, rest = divmod(minutes, 60)
    if not hours:
        return f"{minutes} min"
    return f"{hours} h" if not rest else f"{hours} h {rest} min"


def _pending_by_key(state: Mapping[str, Any], key: str) -> str | None:
    for notification_id, pending in state.get("pending_notifications", {}).items():
        if pending.get("key") == key:
            return notification_id
    return None


def _record_fired(
    state: dict[str, Any], key: str, kind: str, icao: str, now: datetime,
    disposition: str, expires_at: datetime | None = None,
) -> None:
    state["fired"][key] = {
        "kind": kind,
        "icao": icao,
        "fired_at": format_ts(now),
        "expires_at": format_ts(expires_at) if expires_at else None,
        "disposition": disposition,
    }


def _ground_suffix(ground: Any) -> str:
    """Format the optional VATSIM ground-traffic count as a body suffix."""

    if not isinstance(ground, int) or isinstance(ground, bool):
        return ""
    if ground == 0:
        return " · no traffic on ground"
    return f" · {ground} on ground"


def sticky_kinds(cfg: Mapping[str, Any]) -> frozenset[str]:
    """The configured sticky selectors as a set, for _queue and _render."""

    return frozenset(cfg.get("sticky_notifications") or ())


def _is_sticky(kind: str, data: Mapping[str, Any], sticky: frozenset[str]) -> bool:
    """Does this notification stay up until the user dismisses it?

    ``upgrade`` carries a position, so it can be selected either wholesale
    ("upgrade", every radar position) or one position at a time
    ("upgrade:APP") — the finer entry is what makes "APP matters, the rest
    does not" expressible without a second config key.
    """

    if kind in sticky:
        return True
    if kind == "upgrade":
        return f"upgrade:{data.get('position')}" in sticky
    return False


def _render(
    kind: str, icao: str | None, data: Mapping[str, Any],
    sticky: frozenset[str] = frozenset(), now: datetime | None = None,
) -> tuple[str, str, bool]:
    """Title, body, and whether the popup stays until it is dismissed.

    The third value used to mean "urgent" and drove the notification urgency
    *and* the warning icon.  It now means "sticky" alone: a live airport is
    good news that may deserve to wait for the user, but it must not look like
    a fault.  The icon follows the kind, not this flag (platform_backend).

    A sticky ``live``/``lead`` popup carries the time it was observed, for the
    same reason ``render_digest`` stamps every away line: a popup that has been
    standing for two hours states something about *then*, not about now — the
    controller may long be gone (``stale_max_min`` calls VATSIM data unusable
    after 20 minutes).  A popup that fades after eight seconds needs no stamp;
    there the time would be noise.
    """

    name = str(data.get("name", "")).strip()
    label = " ".join(part for part in (icao or "", name) if part)
    positions = " ".join(data.get("positions", []))
    stays = _is_sticky(kind, data, sticky)
    seen = f" · since {_hm(now)}" if stays and now is not None else ""
    if kind == "live":
        body = (positions or "ATC is online") + _ground_suffix(data.get("ground"))
        return f"● {label} is live", body + seen, stays
    if kind == "upgrade":
        pos = data.get("position", "Radar")
        return f"{label} — {pos} now online", positions, stays
    if kind == "lead":
        minutes = data.get("minutes", 0)
        return f"{label} in {minutes} min", (
            f"{positions} booked {data.get('start_hm')}–{data.get('end_hm')}z"
            + seen
        ), stays
    if kind == "cancelled":
        return f"{icao} {data.get('start_hm')}z booking is gone", (
            f"The announced {positions or 'ATC'} slot is no longer scheduled."
        ), stays
    if kind == "event":
        body = f"{data.get('start')}–{data.get('end_hm')}z"
        link = data.get("link")
        if isinstance(link, str) and link.strip():
            body += f" · {link.strip()}"
        return f"Event at {icao} — {data.get('event_name')}", body, stays
    if kind == "health":
        # Not configurable: while the tool is blind, every other notification
        # is missing too, so this one always waits for the user.
        return "atcwatch: monitoring is unhealthy", str(data.get("detail", "")), True
    if kind == "recovery":
        return "atcwatch: back online", "Fresh, fully validated ATC data is available again.", False
    raise ValueError(f"unsupported notification kind {kind!r}")


def _queue(
    state: dict[str, Any], *, key: str, kind: str, now: datetime,
    icao: str | None = None, data: Mapping[str, Any] | None = None,
    expires_at: datetime | None = None, allow_pause_suppression: bool = True,
    log: list[str] | None = None, sticky: frozenset[str] = frozenset(),
) -> str:
    """Queue or consume a notification and return its disposition.

    ``sticky`` defaults to empty so the operational callers (health, recovery,
    events) need no config threading: none of them is configurable anyway.
    """

    payload = dict(data or {})
    if kind in DOMAIN_KINDS and (key in state["fired"] or _pending_by_key(state, key)):
        disposition = state["fired"].get(key, {}).get("disposition", "pending")
        if log is not None:
            log.append(f"dedup {key}: already fired ({disposition})")
        return disposition
    if allow_pause_suppression and kind in DOMAIN_KINDS and _is_paused(state, now):
        if icao is None:
            raise ValueError("domain notifications require an ICAO")
        _record_fired(state, key, kind, icao, now, "suppressed_pause", expires_at)
        if log is not None:
            log.append(f"dedup {key}: suppressed_pause")
        return "suppressed_pause"
    title, body, urgent = _render(kind, icao, payload, sticky, now)
    notification_id = _notification_id(key)
    state["pending_notifications"][notification_id] = {
        "id": notification_id,
        "key": key,
        "kind": kind,
        "icao": icao,
        "title": title,
        "body": body,
        "urgent": urgent,
        "created_at": format_ts(now),
        "data": payload,
    }
    if kind in DOMAIN_KINDS:
        if icao is None:
            raise ValueError("domain notifications require an ICAO")
        _record_fired(state, key, kind, icao, now, "pending", expires_at)
    if log is not None:
        log.append(f"queue {kind} {key}: pending")
    return "pending"


def pending_notifications(state: Mapping[str, Any]) -> tuple[Notification, ...]:
    records = sorted(
        state.get("pending_notifications", {}).values(),
        key=lambda value: (value.get("created_at", ""), value.get("id", "")),
    )
    return tuple(Notification.from_record(record) for record in records)


def acknowledge_delivery(
    state: Mapping[str, Any], notification_id: str, delivered_at: datetime,
) -> dict[str, Any]:
    """Acknowledge one actually delivered outbox item."""

    result = deepcopy(state)
    pending = result["pending_notifications"].pop(notification_id, None)
    if pending is None:
        return result
    kind = pending["kind"]
    key = pending["key"]
    if key in result["fired"]:
        result["fired"][key]["disposition"] = "notified"
        result["fired"][key]["fired_at"] = format_ts(delivered_at)
    if kind == "lead":
        announcement_key = pending.get("data", {}).get("announcement_key", key)
        announcement = result["announced_leads"].get(announcement_key)
        if announcement:
            announcement["disposition"] = "notified"
            announcement["notified_at"] = format_ts(delivered_at)
    elif kind == "cancelled":
        announcement_key = pending.get("data", {}).get("announcement_key")
        announcement = result["announced_leads"].get(announcement_key)
        if announcement:
            announcement["status"] = "cancelled"
    elif kind == "health":
        result["health_warning"] = {
            "kind": pending.get("data", {}).get("health_kind", "unknown"),
            "delivered_at": format_ts(delivered_at),
        }
    elif kind == "recovery":
        result["health_warning"] = None
    return result


def _drop_pending_key(state: dict[str, Any], key: str) -> None:
    notification_id = _pending_by_key(state, key)
    if notification_id:
        state["pending_notifications"].pop(notification_id, None)


def consume_pending_while_paused(state: Mapping[str, Any], now: datetime) -> dict[str, Any]:
    """Consume domain outbox items during pause; health remains retryable."""

    result = deepcopy(state)
    if not _is_paused(result, now):
        return result
    for notification_id, pending in list(result["pending_notifications"].items()):
        if pending.get("kind") not in DOMAIN_KINDS:
            # A health warning is not marked delivered while paused.  Drop
            # the candidate so it can be reconsidered after pause.
            result["pending_notifications"].pop(notification_id, None)
            continue
        key = pending["key"]
        fired = result["fired"].get(key)
        if fired:
            fired["disposition"] = "suppressed_pause"
        if pending["kind"] == "lead":
            # The user never saw this announcement; per plan 2.4 it must not
            # survive as a revocable promise.
            announcement_key = pending.get("data", {}).get("announcement_key")
            result["announced_leads"].pop(announcement_key, None)
        result["pending_notifications"].pop(notification_id, None)
    return result


def cleanup_removed_airports(state: Mapping[str, Any], watched_icaos: Iterable[str]) -> dict[str, Any]:
    """Remove every state/ledger/outbox trace of no-longer-watched airports."""

    result = deepcopy(state)
    watched = set(watched_icaos)
    result["airports"] = {
        icao: value for icao, value in result["airports"].items() if icao in watched
    }
    result["unknown_icaos"] = [icao for icao in result["unknown_icaos"] if icao in watched]
    result["fired"] = {
        key: value for key, value in result["fired"].items()
        if (
            value.get("icao") in watched if isinstance(value, dict)
            else key.split("|", 1)[0] in watched
        )
    }
    result["announced_leads"] = {
        key: value for key, value in result["announced_leads"].items()
        if value.get("icao") in watched
    }
    result["pending_notifications"] = {
        key: value for key, value in result["pending_notifications"].items()
        if value.get("icao") is None or value.get("icao") in watched
    }
    return result


def invalidate_offline_evidence(state: Mapping[str, Any]) -> dict[str, Any]:
    result = deepcopy(state)
    for airport in result["airports"].values():
        airport["offline_since"] = None
    return result


def _airport_state(snapshot: AirportSnapshot, now: datetime) -> dict[str, Any]:
    online = list(snapshot.online_positions)
    return {
        "initialized": True,
        "status": snapshot.status,
        "name": snapshot.name,
        "online_positions": online,
        "ground": snapshot.ground,
        "radar_seen": [pos for pos in snapshot.online_positions if pos in RADAR_POSITIONS],
        "live_since": format_ts(now) if snapshot.online_positions else None,
        "offline_since": format_ts(now) if not snapshot.online_positions else None,
        "last_seen": format_ts(now),
        "next_start": format_ts(snapshot.next_start) if snapshot.next_start else None,
        "next_end": format_ts(snapshot.next_end) if snapshot.next_end else None,
    }


def _booking_slots(snapshot: AirportSnapshot, now: datetime) -> tuple[Slot, ...]:
    return tuple(
        slot for slot in snapshot.slots
        if slot.start > now and slot.booking_positions
    )


def _lead_identity(icao: str, slot: Slot) -> str:
    """Exact promise identity: ICAO, exact start/end, booked positions."""

    positions = "+".join(slot.booking_positions)
    return f"{icao}|{format_ts(slot.start)}|{format_ts(slot.end)}|{positions}"


def _announcement(icao: str, slot: Slot, now: datetime, disposition: str) -> dict[str, Any]:
    return {
        "icao": icao,
        "start": format_ts(slot.start),
        "end": format_ts(slot.end),
        "positions": list(slot.booking_positions),
        "disposition": disposition,
        "missing_fresh_runs": 0,
        "status": "active",
        "created_at": format_ts(now),
        "notified_at": None,
    }


def _seed_airport(
    state: dict[str, Any], snapshot: AirportSnapshot, cfg: Mapping[str, Any],
    now: datetime, log: list[str] | None = None,
) -> None:
    icao = snapshot.icao
    state["airports"][icao] = _airport_state(snapshot, now)
    if log is not None:
        log.append(f"seed {icao}: silent state adoption ({snapshot.status})")
    if snapshot.online_positions:
        _record_fired(state, f"{icao}|live", "live", icao, now, "suppressed_seed")
        for pos in set(snapshot.online_positions).intersection(RADAR_POSITIONS):
            _record_fired(
                state, f"{icao}|upgrade|{pos}", "upgrade", icao, now,
                "suppressed_seed")
    lead_horizon = timedelta(minutes=int(cfg["notify_lead_min"]))
    for slot in _booking_slots(snapshot, now):
        if slot.start - now > lead_horizon:
            continue
        # The seed consumes the normal fired key but never records a promise:
        # what the user has not seen must not later look like a revoked lead.
        bucket = format_ts(floor_bucket(slot.start))
        _record_fired(
            state, f"{icao}|lead|{bucket}", "lead", icao, now,
            "suppressed_seed", slot.end + timedelta(hours=LEAD_GUARD_HOURS))
    event_limit = now + timedelta(days=EVENT_NOTIFY_HORIZON_DAYS)
    for event in snapshot.events:
        if event.end <= now or event.start > event_limit:
            continue
        key = _event_key(icao, event.identity)
        _record_fired(
            state, key, "event", icao, now, "suppressed_seed", event.end)


def _clear_session_ledger(state: dict[str, Any], icao: str) -> None:
    for key, fired in list(state["fired"].items()):
        if fired.get("icao") == icao and fired.get("kind") in {"live", "upgrade"}:
            _drop_pending_key(state, key)
            state["fired"].pop(key, None)


def _process_session(
    state: dict[str, Any], previous: dict[str, Any], current: AirportSnapshot,
    cfg: Mapping[str, Any], now: datetime, prior_fresh: datetime | None,
    log: list[str] | None = None,
) -> None:
    icao = current.icao
    previous_online = set(previous.get("online_positions", []))
    current_online = set(current.online_positions)
    was_live = bool(previous_online)
    is_live = bool(current_online)
    max_gap = timedelta(minutes=max(15, int(cfg["poll_min"]) * 2))
    observation_contiguous = prior_fresh is not None and now - prior_fresh <= max_gap
    if log is not None:
        log.append(
            f"{icao}: online {sorted(previous_online)} -> {sorted(current_online)}"
            f" (contiguous={observation_contiguous})")

    if is_live:
        offline_since = try_parse_ts(previous.get("offline_since"))
        last_seen = try_parse_ts(previous.get("last_seen"))
        cooldown = timedelta(minutes=int(cfg["offline_cooldown_min"]))
        # Proven offline time is the span between the first and the last
        # *observed* offline snapshot (plan 2.4) — the gap between the last
        # offline observation and this online one is never evidence.
        if (
            not was_live and offline_since is not None and last_seen is not None
            and last_seen - offline_since >= cooldown
            and now - last_seen <= max_gap
        ):
            _clear_session_ledger(state, icao)
            previous["radar_seen"] = []
            previous["live_since"] = None
        elif not was_live and not observation_contiguous:
            # The ledger answers one question only: has the user already been
            # told about *this* session?  Across a break in observation
            # (machine off, daemon stopped, a long stale phase) it cannot
            # answer it any more — nothing was watched, so nothing was told.
            # Keeping the key then suppresses the next genuine live alarm for
            # as long as the airport stays busy, because the offline phase it
            # would take to clear the key was never seen either.  A stale
            # answer is worse than none: drop it and let the transition speak.
            _clear_session_ledger(state, icao)
            previous["radar_seen"] = []
            previous["live_since"] = None
            if log is not None:
                log.append(
                    f"{icao}: ledger dropped, observation broke before this "
                    "session (no evidence the user saw it)")
        if not was_live and f"{icao}|live" not in state["fired"]:
            _queue(
                state, key=f"{icao}|live", kind="live", icao=icao, now=now,
                data={
                    "name": current.name,
                    "positions": list(current.online_positions),
                    "ground": current.ground,
                },
                log=log, sticky=sticky_kinds(cfg),
            )
        elif not was_live and log is not None:
            # The veto short-circuits before _queue, so its own dedup line
            # never runs: an offline->live transition dies here without a
            # trace, indistinguishable in the log from a quiet airport.  The
            # ledger key only survives this far when the offline phase went
            # unobserved (see _clear_session_ledger callers) -- exactly the
            # case that is hard to reconstruct afterwards.
            fired = state["fired"].get(f"{icao}|live", {})
            log.append(
                f"dedup {icao}|live: already fired "
                f"({fired.get('disposition', 'unknown')} at "
                f"{fired.get('fired_at', 'unknown')}); live alert suppressed")
        if was_live:
            seen = set(previous.get("radar_seen", []))
            for pos in POSITION_ORDER:
                if pos not in RADAR_POSITIONS or pos not in current_online or pos in seen:
                    continue
                key = f"{icao}|upgrade|{pos}"
                if bool(cfg["notify_upgrades"]):
                    _queue(
                        state, key=key, kind="upgrade", icao=icao, now=now,
                        data={
                            "name": current.name,
                            "position": pos,
                            "positions": list(current.online_positions),
                        },
                        log=log, sticky=sticky_kinds(cfg),
                    )
                elif log is not None:
                    log.append(f"dedup {key}: notify_upgrades disabled")
        previous["radar_seen"] = [
            pos for pos in POSITION_ORDER
            if pos in RADAR_POSITIONS and (
                pos in set(previous.get("radar_seen", [])) or pos in current_online)
        ]
        previous["live_since"] = (
            previous.get("live_since") if was_live and previous.get("live_since")
            else format_ts(now)
        )
        previous["offline_since"] = None
    else:
        offline_since = try_parse_ts(previous.get("offline_since"))
        if was_live or offline_since is None or not observation_contiguous:
            offline_since = now
            previous["offline_since"] = format_ts(now)
        cooldown = timedelta(minutes=int(cfg["offline_cooldown_min"]))
        if observation_contiguous and now - offline_since >= cooldown:
            _clear_session_ledger(state, icao)
            previous["radar_seen"] = []
            previous["live_since"] = None

    previous.update({
        "initialized": True,
        "status": current.status,
        "name": current.name,
        "online_positions": list(current.online_positions),
        "ground": current.ground,
        "last_seen": format_ts(now),
        "next_start": format_ts(current.next_start) if current.next_start else None,
        "next_end": format_ts(current.next_end) if current.next_end else None,
    })


def _lead_guard_active(state: Mapping[str, Any], icao: str, now: datetime) -> bool:
    """At most one real lead announcement per airport per LEAD_GUARD_HOURS.

    Only genuine (queued or delivered) announcements count.  Seed and pause
    suppressions are not announcements and must not block real leads.
    """

    threshold = now - timedelta(hours=LEAD_GUARD_HOURS)
    for announcement in state["announced_leads"].values():
        if announcement.get("icao") != icao:
            continue
        if announcement.get("disposition") not in {"pending", "notified"}:
            continue
        created = try_parse_ts(announcement.get("created_at"))
        if created and created > threshold:
            return True
    return False


def _slot_data(slot: Slot, now: datetime) -> dict[str, Any]:
    return {
        "positions": list(slot.booking_positions),
        "minutes": max(0, int((slot.start - now).total_seconds() // 60)),
        "start_hm": slot.start.strftime("%H:%M"),
        "end_hm": slot.end.strftime("%H:%M"),
    }


def _pending_for_announcement(
    state: Mapping[str, Any], announcement_key: str,
) -> bool:
    """Is an outbox item still waiting for this exact promise?

    A queued lead carries the bucket key, not the promise identity, so a plain
    ``_pending_by_key`` lookup would never find it.
    """

    return any(
        pending.get("data", {}).get("announcement_key") == announcement_key
        for pending in state.get("pending_notifications", {}).values()
    )


def _drop_pending_lead(state: dict[str, Any], announcement_key: str) -> None:
    for notification_id, pending in list(state["pending_notifications"].items()):
        if (pending.get("kind") == "lead"
                and pending.get("data", {}).get("announcement_key") == announcement_key):
            fired = state["fired"].get(pending["key"])
            if fired:
                fired["disposition"] = "withdrawn"
            state["pending_notifications"].pop(notification_id, None)


def _process_leads_and_cancellations(
    state: dict[str, Any], current: AirportSnapshot,
    cfg: Mapping[str, Any], now: datetime, log: list[str] | None = None,
) -> None:
    icao = current.icao
    slots = _booking_slots(current, now)
    identities = {_lead_identity(icao, slot): slot for slot in slots}

    # A slot that is still scheduled resets its promise's missing counter.
    for identity, announcement in state["announced_leads"].items():
        if (announcement.get("icao") == icao
                and announcement.get("status") == "active"
                and identity in identities):
            announcement["missing_fresh_runs"] = 0

    lead_horizon = timedelta(minutes=int(cfg["notify_lead_min"]))
    for identity, slot in identities.items():
        if slot.start - now > lead_horizon:
            continue
        if identity in state["announced_leads"]:
            continue
        # The 15-minute bucket is a spam brake for small slot wobble only;
        # promise identity and every domain decision use the exact times.
        bucket_key = f"{icao}|lead|{format_ts(floor_bucket(slot.start))}"
        if bucket_key in state["fired"] or _pending_by_key(state, bucket_key):
            if log is not None:
                log.append(f"dedup {bucket_key}: lead bucket already fired")
            continue
        if _lead_guard_active(state, icao, now):
            _record_fired(
                state, bucket_key, "lead", icao, now, "suppressed_guard",
                slot.end + timedelta(hours=LEAD_GUARD_HOURS))
            if log is not None:
                log.append(
                    f"dedup {bucket_key}: suppressed_guard "
                    f"(one lead per {LEAD_GUARD_HOURS} h per airport)")
            continue
        data = _slot_data(slot, now)
        data.update({"name": current.name, "announcement_key": identity})
        disposition = _queue(
            state, key=bucket_key, kind="lead", icao=icao, now=now, data=data,
            expires_at=slot.end + timedelta(hours=LEAD_GUARD_HOURS), log=log,
            sticky=sticky_kinds(cfg))
        if disposition == "pending":
            # Only a genuinely queued lead becomes a promise; seed and pause
            # suppressions must never look like revocable announcements.
            state["announced_leads"][identity] = _announcement(
                icao, slot, now, "pending")

    for identity, announcement in list(state["announced_leads"].items()):
        if announcement.get("icao") != icao or announcement.get("status") != "active":
            continue
        if identity in identities:
            continue
        start = parse_ts(announcement["start"])
        disposition = announcement["disposition"]
        if disposition == "pending":
            if deliveries_held(state):
                # Away: the queued announcement was never shown, and the whole
                # point of the away digest is to report it.  Withdrawing it
                # here would silently empty the digest, so the promise stays
                # active until the outbox is really delivered.
                continue
            _drop_pending_lead(state, identity)
            announcement["status"] = "withdrawn"
            continue
        if now >= start:
            announcement["status"] = "completed"
            continue
        if current.online_positions:
            announcement["status"] = "fulfilled"
            continue
        replacement = next(
            (
                slot for slot in slots
                if abs((slot.start - start).total_seconds())
                <= CANCEL_RESCHEDULE_WINDOW_MIN * 60
            ),
            None,
        )
        if replacement is not None:
            new_identity = _lead_identity(icao, replacement)
            if (disposition == "notified"
                    and new_identity not in state["announced_leads"]):
                # The promise follows the rescheduled slot (plan 2.4 #6):
                # a small move must not end the announcement's life, or a
                # later disappearance of the replacement would never be
                # reported as a cancellation.
                moved = dict(announcement)
                moved.update({
                    "start": format_ts(replacement.start),
                    "end": format_ts(replacement.end),
                    "positions": list(replacement.booking_positions),
                    "missing_fresh_runs": 0,
                    "status": "active",
                })
                state["announced_leads"].pop(identity, None)
                state["announced_leads"][new_identity] = moved
                if log is not None:
                    log.append(
                        f"lead {identity}: promise moved to {new_identity}")
            else:
                announcement["status"] = "replaced"
                if log is not None:
                    log.append(f"lead {identity}: replaced by a nearby slot")
            continue
        if disposition != "notified":
            announcement["status"] = "withdrawn"
            continue
        announcement["missing_fresh_runs"] += 1
        if log is not None:
            log.append(
                f"lead {identity}: missing fresh run "
                f"{announcement['missing_fresh_runs']}/2")
        if announcement["missing_fresh_runs"] < 2:
            continue
        cancel_key = f"{icao}|cancelled|{identity}"
        data = {
            "announcement_key": identity,
            "start_hm": start.strftime("%H:%M"),
            "positions": announcement["positions"],
        }
        _queue(
            state, key=cancel_key, kind="cancelled", icao=icao, now=now,
            data=data,
            expires_at=parse_ts(announcement["end"]) + timedelta(hours=LEAD_GUARD_HOURS),
            log=log, sticky=sticky_kinds(cfg),
        )
        announcement["status"] = "cancellation_pending"


def _process_events(
    state: dict[str, Any], current: AirportSnapshot, now: datetime,
    log: list[str] | None = None, sticky: frozenset[str] = frozenset(),
) -> None:
    limit = now + timedelta(days=EVENT_NOTIFY_HORIZON_DAYS)
    for event in current.events:
        if event.end <= now or event.start > limit:
            if log is not None:
                log.append(
                    f"{current.icao}: event {event.identity} outside the "
                    f"{EVENT_NOTIFY_HORIZON_DAYS}-day horizon, no ledger entry")
            continue
        key = _event_key(current.icao, event.identity)
        data = {
            "event_name": event.name,
            "start": event.start.strftime("%a %d %b, %H:%M"),
            "end_hm": event.end.strftime("%H:%M"),
            "link": event.link,
        }
        _queue(
            state, key=key, kind="event", icao=current.icao, now=now,
            data=data, expires_at=event.end, log=log, sticky=sticky)


def _cancel_undelivered_health(state: dict[str, Any]) -> None:
    for notification_id, pending in list(state["pending_notifications"].items()):
        if pending.get("kind") == "health":
            state["pending_notifications"].pop(notification_id, None)


def _queue_recovery_if_needed(state: dict[str, Any], now: datetime) -> None:
    warning = state.get("health_warning")
    if not isinstance(warning, dict) or not warning.get("delivered_at"):
        _cancel_undelivered_health(state)
        return
    key = f"recovery|{warning['delivered_at']}"
    if _pending_by_key(state, key) is None:
        _queue(
            state, key=key, kind="recovery", now=now,
            data={}, allow_pause_suppression=False)


def process_fresh_snapshot(
    state: Mapping[str, Any], snapshot: Snapshot, watched_icaos: Iterable[str],
    cfg: Mapping[str, Any], now: datetime,
) -> Decision:
    """Apply one fully validated, fresh snapshot."""

    log: list[str] = []
    live_count = sum(bool(a.online_positions) for a in snapshot.airports.values())
    upcoming_count = sum(a.status == "upcoming" for a in snapshot.airports.values())
    log.append(
        f"payload: {len(snapshot.airports)} airports, {live_count} live, "
        f"{upcoming_count} upcoming, generated_at "
        f"{format_ts(snapshot.generated_at)}, vatsim "
        + (format_ts(snapshot.vatsim_generated_at)
           if snapshot.vatsim_generated_at else "null"))
    result = cleanup_removed_airports(state, watched_icaos)
    result = consume_pending_while_paused(result, now)
    prior_fresh = try_parse_ts(result.get("last_fresh_data"))
    _queue_recovery_if_needed(result, now)
    result.update({
        "last_http_success": format_ts(now),
        "last_fresh_data": format_ts(now),
        "fail_count": 0,
        "first_fail_at": None,
        "next_try": None,
        "transport_error_since": None,
        "stale_since": None,
        "local_error_since": None,
        "last_outcome": "fresh",
        "last_error": None,
    })

    if not result.get("seeded"):
        result["airports"] = {}
        result["fired"] = {}
        result["announced_leads"] = {}
        # Recovery/health are operational, not domain seed state.
        result["pending_notifications"] = {
            key: value for key, value in result["pending_notifications"].items()
            if value.get("kind") in {"health", "recovery"}
        }
        for airport in snapshot.airports.values():
            _seed_airport(result, airport, cfg, now, log)
        result["seeded"] = True
    else:
        for icao, current in snapshot.airports.items():
            previous = result["airports"].get(icao)
            if previous is None or not previous.get("initialized"):
                _seed_airport(result, current, cfg, now, log)
                continue
            if current.status == "unknown":
                previous.update({
                    "status": "unknown", "name": "", "last_seen": format_ts(now),
                    "next_start": None, "next_end": None,
                })
                continue
            _process_session(result, previous, current, cfg, now, prior_fresh, log)
            _process_leads_and_cancellations(result, current, cfg, now, log)
            _process_events(result, current, now, log, sticky_kinds(cfg))

    result["unknown_icaos"] = sorted(
        icao for icao, airport in snapshot.airports.items()
        if airport.status == "unknown")
    result = prune_state(result, now)
    summary = summarize(result, snapshot)
    return Decision(result, pending_notifications(result), summary, tuple(log))


def _remove_pending_kind(state: dict[str, Any], kind: str) -> None:
    for notification_id, pending in list(state["pending_notifications"].items()):
        if pending.get("kind") == kind:
            state["pending_notifications"].pop(notification_id, None)


def _current_issue(state: Mapping[str, Any]) -> tuple[str, datetime, str] | None:
    """Earliest open operational-error anchor.

    Plan §4 defines the health window as starting at the *first* operational
    failure since the last fresh payload.  The failure kinds therefore share
    one common window: the earliest anchor wins, and recording one kind never
    resets another (only a fresh payload clears them all).
    """

    options = (
        ("transport", state.get("transport_error_since")),
        ("stale", state.get("stale_since")),
        ("local", state.get("local_error_since")),
    )
    candidates = [
        (since, kind)
        for kind, value in options
        if (since := try_parse_ts(value)) is not None
    ]
    if not candidates:
        return None
    since, kind = min(candidates)
    return kind, since, str(state.get("last_error") or kind)


def _maybe_queue_health(
    state: dict[str, Any], now: datetime, *, immediate: bool = False,
) -> None:
    if state.get("health_warning") is not None or _is_paused(state, now):
        return
    issue = _current_issue(state)
    if issue is None:
        return
    kind, since, detail = issue
    if kind == "local" and not state.get("last_fresh_data"):
        # A broken config/watchlist starts the health window only after
        # previously healthy operation (plan 8.1/8.2).  A never-configured
        # fresh install shows the error icon but must not alarm.
        return
    if not immediate and now - since < timedelta(minutes=HEALTH_WARNING_MIN):
        return
    key = f"health|{kind}|{format_ts(since)}"
    if _pending_by_key(state, key):
        return
    _queue(
        state, key=key, kind="health", now=now,
        data={"health_kind": kind, "detail": detail},
        allow_pause_suppression=False)


def begin_attempt(state: Mapping[str, Any], now: datetime) -> dict[str, Any]:
    result = deepcopy(state)
    result["last_attempt"] = format_ts(now)
    return result


def record_failure(
    state: Mapping[str, Any], *, outcome: str, detail: str, now: datetime,
    cfg: Mapping[str, Any], http_success_contact: bool = False,
    retry_after_at: datetime | None = None,
) -> Decision:
    """Record a transport/protocol/auth failure and its backoff.

    ``http_success_contact`` means an HTTP 2xx response was received and its
    body then rejected (protocol/payload error).  Only that moves
    ``last_http_success`` — an error status like 401/403/500 never does
    (plan 6.3: the field means "HTTP 200 received").
    """

    result = consume_pending_while_paused(state, now)
    _remove_pending_kind(result, "recovery")
    result = invalidate_offline_evidence(result)
    if http_success_contact:
        result["last_http_success"] = format_ts(now)
    result["fail_count"] = int(result.get("fail_count", 0)) + 1
    if result.get("first_fail_at") is None:
        result["first_fail_at"] = format_ts(now)
    if result.get("transport_error_since") is None:
        result["transport_error_since"] = format_ts(now)
    result["last_outcome"] = outcome
    result["last_error"] = detail or outcome
    poll_min = int(cfg["poll_min"])
    if outcome == "forbidden":
        result["next_try"] = format_ts(now + timedelta(minutes=60))
    elif outcome == "rate_limited" and retry_after_at is not None:
        result["next_try"] = format_ts(max(now, retry_after_at))
    elif outcome == "invalid_request":
        result["next_try"] = format_ts(now + timedelta(minutes=poll_min))
    else:
        delay = min(poll_min * (2 ** (result["fail_count"] - 1)), 30)
        result["next_try"] = format_ts(now + timedelta(minutes=delay))
    _maybe_queue_health(result, now, immediate=outcome == "forbidden")
    return Decision(result, pending_notifications(result), summarize(result, None))


def record_stale(
    state: Mapping[str, Any], *, detail: str, now: datetime,
    cfg: Mapping[str, Any],
) -> Decision:
    result = consume_pending_while_paused(state, now)
    _remove_pending_kind(result, "recovery")
    result = invalidate_offline_evidence(result)
    result["last_http_success"] = format_ts(now)
    result["fail_count"] = 0
    result["first_fail_at"] = None
    result["next_try"] = format_ts(now + timedelta(minutes=int(cfg["poll_min"])))
    if result.get("stale_since") is None:
        result["stale_since"] = format_ts(now)
    result["last_outcome"] = "stale"
    result["last_error"] = detail
    _maybe_queue_health(result, now)
    return Decision(result, pending_notifications(result), summarize(result, None))


def record_local_error(
    state: Mapping[str, Any], *, outcome: str, detail: str, now: datetime,
    health_window: bool = True,
) -> Decision:
    """Record a config/watchlist problem.

    ``health_window=False`` is for the deliberately quiet case "watchlist.json
    is missing" (plan 8.2): the error icon is the whole message, no health
    warning may ever grow out of it.
    """

    result = consume_pending_while_paused(state, now)
    _remove_pending_kind(result, "recovery")
    result = invalidate_offline_evidence(result)
    if not health_window:
        result["local_error_since"] = None
    elif result.get("local_error_since") is None:
        result["local_error_since"] = format_ts(now)
    result["last_outcome"] = outcome
    result["last_error"] = detail
    _maybe_queue_health(result, now)
    return Decision(result, pending_notifications(result), summarize(result, None))


def prune_state(state: Mapping[str, Any], now: datetime) -> dict[str, Any]:
    result = deepcopy(state)
    held = deliveries_held(result)
    for key, announcement in list(result["announced_leads"].items()):
        end = try_parse_ts(announcement.get("end"))
        if end and end + timedelta(hours=LEAD_GUARD_HOURS) <= now:
            if held and (_pending_by_key(result, key) is not None
                         or _pending_for_announcement(result, key)):
                # An 8-hour absence outlives the lead guard.  Pruning an
                # undelivered outbox item would make the away digest come out
                # empty, so both the promise and its queued popup survive
                # until the user is back (see apply_away).
                continue
            result["announced_leads"].pop(key, None)
            _drop_pending_key(result, key)
    for key, fired in list(result["fired"].items()):
        expiry = try_parse_ts(fired.get("expires_at"))
        if expiry and expiry <= now and _pending_by_key(result, key) is None:
            result["fired"].pop(key, None)
    if len(result["fired"]) > MAX_FIRED:
        pending_keys = {
            item.get("key") for item in result["pending_notifications"].values()
        }
        removable = sorted(
            (
                (value.get("fired_at", ""), key)
                for key, value in result["fired"].items()
                if key not in pending_keys
            ),
        )
        for _, key in removable[:max(0, len(result["fired"]) - MAX_FIRED)]:
            result["fired"].pop(key, None)
    return result


def summarize(state: Mapping[str, Any], snapshot: Snapshot | None) -> dict[str, Any]:
    if snapshot is None:
        airports = state.get("airports", {})
        live = sum(bool(value.get("online_positions")) for value in airports.values())
        upcoming = sum(value.get("status") == "upcoming" for value in airports.values())
    else:
        live = sum(bool(value.online_positions) for value in snapshot.airports.values())
        upcoming = sum(value.status == "upcoming" for value in snapshot.airports.values())
    return {
        "live": live,
        "upcoming": upcoming,
        "unknown": len(state.get("unknown_icaos", [])),
        "pending": len(state.get("pending_notifications", {})),
        "outcome": state.get("last_outcome"),
    }


def due_at(
    state: Mapping[str, Any], cfg: Mapping[str, Any],
    now: datetime | None = None,
) -> datetime | None:
    next_try = try_parse_ts(state.get("next_try"))
    if next_try:
        return next_try
    last_attempt = try_parse_ts(state.get("last_attempt"))
    if last_attempt:
        if now is not None and last_attempt > now:
            # Wall clock jumped backwards (plan 7.3): a future last_attempt
            # must not push the next poll far away — clamp it to now.
            last_attempt = now
        return last_attempt + timedelta(minutes=int(cfg["poll_min"]))
    return None


def record_unexpected_failure(
    state: Mapping[str, Any], detail: str, now: datetime,
) -> dict[str, Any]:
    """In-memory pacing after an unexpected cycle crash (no reducer ran).

    Setting ``last_attempt`` keeps ``due_at`` authoritative, so a repeatedly
    crashing poll is retried once per ``poll_min`` instead of on every
    30-second tick.
    """

    result = deepcopy(state)
    result["last_attempt"] = format_ts(now)
    result["last_outcome"] = "tray_error"
    result["last_error"] = detail
    return result


def rebind_server_origin(
    state: Mapping[str, Any], configured_origin: str,
) -> dict[str, Any]:
    """Bind transport state to the configured origin (plan 6.3).

    A changed ``url`` talks to a different server: backoff, failure counters,
    the transport health anchor, and the session cookie of the old origin
    must not survive the switch.
    """

    if state.get("server_origin") == configured_origin:
        return deepcopy(state)
    result = deepcopy(state)
    result.update({
        "server_origin": configured_origin,
        "next_try": None,
        "fail_count": 0,
        "first_fail_at": None,
        "transport_error_since": None,
        "session_cookie": None,
        "session_cookie_origin": None,
        "session_cookie_at": None,
    })
    return result


def backoff_active(state: Mapping[str, Any], now: datetime) -> bool:
    next_try = try_parse_ts(state.get("next_try"))
    return next_try is not None and next_try > now


class MenuRow(NamedTuple):
    """One tray-menu info line; ``icao`` is ``None`` when it is not clickable."""

    text: str
    icao: str | None


def _hm(value: datetime) -> str:
    return value.astimezone(timezone.utc).strftime("%H:%M") + "z"


def _age_text(now: datetime, then: datetime) -> str:
    minutes = max(0, int((now - then).total_seconds() // 60))
    hours, rest = divmod(minutes, 60)
    if hours:
        return f"{hours} h {rest} min ago"
    return f"{minutes} min ago"


# -- the flight order ---------------------------------------------------------
#
# ATCMatch writes an optional "flight" member into watchlist.json: the flight
# the user just planned, as bare identifiers.  atcwatch is messenger and click
# surface for it and nothing else — it shows the order, hands one click to the
# configured command, and never learns whether the order was flown.  That
# knowledge belongs to whoever applies it (xplane-tools), because it is the
# only one that executes (plan 6, "Wer weiss, dass ein Auftrag ausgefuehrt ist").

# One text field of an order may be this long.  Not a security guard (the file
# is size-capped when it is read) but a menu guard: a hand-edited monster
# string would otherwise stretch the tray menu across the screen.
FLIGHT_FIELD_MAX = 100
FLIGHT_REQUIRED_FIELDS = ("id", "icao", "dest_icao", "aircraft_code", "livery_name")
FLIGHT_OPTIONAL_FIELDS = ("airline", "fltnum", "reg")


@dataclass(frozen=True)
class FlightOrder:
    """The planned flight as it travels in watchlist.json — identifiers only.

    No gate, no ``.acf`` path, no runway: those are made locally from apt.dat,
    liveries.db and METAR by the tool that talks to the sim.  If they were in
    here, sim knowledge would be back in the chain's first link.
    """

    id: str
    icao: str
    dest_icao: str
    aircraft_code: str
    livery_name: str
    airline: str
    fltnum: str
    reg: str
    written_at: datetime


def parse_flight_order(raw: Any) -> tuple[FlightOrder | None, str | None]:
    """Read the optional order, returning ``(order, rejection_reason)``.

    A missing or ``null`` member is the normal case, not a fault: most
    watchlist writes carry no order at all, so it yields ``(None, None)`` and
    stays silent.  Anything present but incomplete is *rejected with a reason*
    instead of being completed by guesswork — a guessed field would end up in
    the simulator, and a silently dropped order would look like the user never
    clicked.  Unknown extra members are ignored: a newer ATCMatch must not
    break an older atcwatch.
    """

    if raw is None:
        return None, None
    if not isinstance(raw, dict):
        return None, "flight must be an object"
    values: dict[str, Any] = {}
    for field in FLIGHT_REQUIRED_FIELDS + FLIGHT_OPTIONAL_FIELDS:
        value = raw.get(field, "" if field in FLIGHT_OPTIONAL_FIELDS else None)
        if not isinstance(value, str):
            return None, f"flight.{field} must be a string"
        value = value.strip()
        if not value and field in FLIGHT_REQUIRED_FIELDS:
            return None, f"flight.{field} is missing"
        if len(value) > FLIGHT_FIELD_MAX:
            return None, f"flight.{field} is longer than {FLIGHT_FIELD_MAX} characters"
        values[field] = value
    for field in ("icao", "dest_icao"):
        if not ICAO_RE.fullmatch(values[field]):
            return None, (
                f"flight.{field} must be four uppercase letters or digits, "
                f"got {values[field]!r}")
    try:
        written_at = parse_ts(raw.get("written_at"))
    except PayloadValidationError as exc:
        return None, f"flight.written_at is invalid: {exc}"
    return FlightOrder(written_at=written_at, **values), None


def flight_row(
    flight: FlightOrder | None, flight_error: str | None, now: datetime,
) -> MenuRow | None:
    """The one tray line for the order, or ``None`` when there is none.

    The age is shown, never judged: there is no expiry semantics here.  A plan
    from yesterday is still the plan the user made, and only the user decides
    whether it is still the one they want.
    """

    if flight_error:
        return MenuRow(f"Flight order rejected: {flight_error}", None)
    if flight is None:
        return None
    aircraft = " / ".join(
        part for part in (flight.aircraft_code, flight.airline) if part)
    return MenuRow(
        f"{flight.icao} → {flight.dest_icao} · {aircraft} · "
        f"plan from {_hm(flight.written_at)} "
        f"({_age_text(now, flight.written_at)})",
        None,
    )


FLIGHT_APPLY_TITLE = "ATCMatch — To the Sim"
FLIGHT_RECEIVED_TITLE = "ATCMatch — Flight received"
# A runaway command must not paint a popup the size of the screen.
FLIGHT_OUTPUT_MAX = 1000


def flight_apply_message(
    returncode: int, stdout: str, stderr: str,
) -> tuple[str, str, bool]:
    """Title, body and warning flag for the result of the apply command.

    The command's own output is passed through unchanged (only truncated at an
    absurd length): atcwatch does not know what applying a flight means, so it
    is in no position to summarise, translate or rate the answer.
    """

    output = (stdout or "").strip() or (stderr or "").strip()
    if len(output) > FLIGHT_OUTPUT_MAX:
        output = output[:FLIGHT_OUTPUT_MAX] + "\u2026"
    failed = returncode != 0
    title = f"{FLIGHT_APPLY_TITLE} — failed" if failed else FLIGHT_APPLY_TITLE
    body = output or f"the command produced no output (exit code {returncode})"
    return title, body, failed


def tray_summary(
    state: Mapping[str, Any], cfg: Mapping[str, Any], now: datetime, *,
    flight: FlightOrder | None = None, flight_error: str | None = None,
) -> tuple[str, str, tuple[MenuRow, ...]]:
    """Pure tray projection: ``(icon_state, tooltip, menu_rows)``.

    Exactly one of the four icon states (plan 5.1) is returned for every data
    situation.  The tooltip always names the time of the last successful
    check (plan 5.3).  No Qt, no I/O, ``now`` is always injected.  Each menu
    row carries the ICAO it names (``None`` when the row is not a deep link
    to a real airport, e.g. an unknown ICAO or a placeholder/error line).

    The optional *flight* order (or the reason it was rejected) becomes the
    first row.  It stands above the airport block and outside the airport row
    cap on purpose: it does not come from the server, so it stays readable
    while the poll is stale, blocked or failing.
    """

    if now.tzinfo is None:
        raise ValueError("now must be timezone-aware")
    outcome = str(state.get("last_outcome") or "new")
    last_fresh = try_parse_ts(state.get("last_fresh_data"))
    lead = timedelta(
        minutes=int(cfg.get("notify_lead_min", DEFAULTS["notify_lead_min"])))

    live_rows: list[MenuRow] = []
    upcoming_rows: list[tuple[datetime, MenuRow]] = []
    for icao in sorted(state.get("airports", {})):
        airport = state["airports"][icao]
        label = " ".join(
            part for part in (icao, str(airport.get("name") or "")) if part)
        online = airport.get("online_positions") or []
        if online:
            live_rows.append(MenuRow(
                f"● {label} — live, {' '.join(online)}"
                f"{_ground_suffix(airport.get('ground'))}",
                icao,
            ))
            continue
        next_start = try_parse_ts(airport.get("next_start"))
        next_end = try_parse_ts(airport.get("next_end"))
        # "Upcoming" covers a slot inside the lead window, including a booked
        # block that already started without a direct online position.
        if (next_start is None or next_end is None or next_end <= now
                or next_start - now > lead):
            continue
        minutes = max(0, int((next_start - now).total_seconds() // 60))
        upcoming_rows.append((
            next_start,
            MenuRow(
                f"○ {label} — in {minutes} min, "
                f"{_hm(next_start)}–{_hm(next_end)}",
                icao,
            ),
        ))
    live = len(live_rows)
    upcoming = len(upcoming_rows)
    rows = live_rows + [row for _, row in sorted(upcoming_rows)]
    rows.extend(
        MenuRow(f"{icao} — unknown airport", None)
        for icao in state.get("unknown_icaos", []))
    if len(rows) > TRAY_MENU_LIMIT:
        hidden = len(rows) - TRAY_MENU_LIMIT
        rows = rows[:TRAY_MENU_LIMIT] + [MenuRow(f"… and {hidden} more", None)]

    last_line = (
        f"Last check {_hm(last_fresh)} ({_age_text(now, last_fresh)})"
        if last_fresh else "No successful check yet")
    if outcome == "new":
        icon = "idle"
        lines = ["atcwatch — waiting for the first check"]
        rows = [MenuRow("Waiting for the first check", None)]
    elif outcome in {"fresh", "empty"}:
        icon = "live" if live else ("upcoming" if upcoming else "idle")
        parts = [text for count, text in (
            (live, f"{live} live"), (upcoming, f"{upcoming} coming up"),
        ) if count]
        lines = [f"atcwatch — {', '.join(parts) or 'all quiet'}", last_line]
        if not rows:
            rows = [MenuRow("No airports watched — open My Airports", None)]
    elif outcome == "watchlist_missing":
        # Not an error: this is what a fresh installation looks like before the
        # user links the watchlist in the My Airports tab. Nothing is broken, so
        # the icon stays idle — tooltip and menu row name the missing step.
        icon = "idle"
        lines = [
            "atcwatch — no watchlist linked",
            "Link the notifier file in the My Airports tab",
        ]
        rows = [MenuRow("No watchlist linked — open My Airports", None)]
    else:
        icon = "error"
        detail = str(state.get("last_error") or outcome)[:120]
        if outcome == "stale":
            headline = "data is stale"
        elif outcome in {"config_error", "watchlist_error", "state_error"}:
            headline = detail
        else:
            headline = "no contact with ATCMatch"
        success_line = (
            f"Last successful check {_hm(last_fresh)} "
            f"({_age_text(now, last_fresh)})"
            if last_fresh else "No successful check yet")
        lines = [f"atcwatch — {headline}", success_line]
        rows = [MenuRow(f"Error: {detail}", None)]

    paused_until = try_parse_ts(state.get("paused_until"))
    if paused_until and paused_until > now:
        lines.append(f"Notifications paused until {_hm(paused_until)}")
    away_since = try_parse_ts(state.get("away_since"))
    if away_since:
        held = len(state.get("pending_notifications", {}))
        lines.append(
            f"Away since {_hm(away_since)} — "
            f"{held} notification{'' if held == 1 else 's'} held")
    order_row = flight_row(flight, flight_error, now)
    if order_row is not None:
        rows = [order_row] + rows
    return icon, "\n".join(lines), tuple(rows)


TEST_ALERT_TITLE = "ATCMatch Desktop Notifier"


def test_alert_message(now: datetime) -> tuple[str, str]:
    """Title and body for the diagnostic test notification (--test-alert).

    The UTC timestamp lets the user tell repeated tests apart.
    """

    if now.tzinfo is None:
        raise ValueError("now must be timezone-aware")
    return (
        TEST_ALERT_TITLE,
        f"Test alert — notifications are working. ({format_ts(now)})",
    )


def test_alert_failure_hint(platform: str) -> str:
    """One actionable diagnosis line for a failed test notification."""

    if platform == "linux":
        return (
            "test alert could not be delivered — check that notify-send is "
            "installed and that DBUS_SESSION_BUS_ADDRESS points at your "
            "graphical session bus")
    return (
        "test alert could not be delivered — check the notification "
        "permission for the visible sender (osascript or the atcwatch "
        "bundle) under System Settings → Notifications, and disable "
        "Focus/Do Not Disturb while testing")


def render_digest(
    notifications: Iterable[Notification], compact: bool = False,
    away_for: timedelta | None = None,
) -> tuple[str, str, bool]:
    """Render one collected notification for a popup salvo (plan 2.4, 9.3).

    With *away_for* the digest is the catch-up report for a locked screen: it
    says so in the title and stamps every line with the time the event was
    seen — "EDDM in 45 min" delivered two hours later would otherwise read as
    a statement about now.
    """

    items = tuple(notifications)
    if not items:
        raise ValueError("a digest requires at least one notification")
    if away_for is None:
        title = f"{len(items)} updates on your airports"
        lines = [item.title for item in items]
    else:
        title = f"While you were away ({format_away(away_for)})"
        lines = [f"{_hm(item.created_at)} {item.title}" for item in items]
    urgent = any(item.urgent for item in items)
    if compact:
        extra = len(lines) - 3
        body = ", ".join(lines[:3]) + (f", +{extra}" if extra > 0 else "")
    else:
        body = "\n".join(lines)
    return title, body, urgent
