"""Qt tray frontend for atcwatch — the only module that imports PySide6.

The tray is a thin shell around the headless cycle: a 30-second wall-clock
tick asks ``due_at`` whether a poll is due (plan 7.3 — this single mechanism
covers suspend/resume, clock jumps, and DST without any platform API), a
background thread runs ``run_poll`` so the GUI never blocks, and the pure
``core.tray_summary`` projection drives icon, tooltip, and menu.

Design note versus plan 7.4: the worker thread executes the complete
headless cycle (``run_poll``: fetch, validate, reduce, persist, notify)
instead of only the HTTP call.  That is safe here because the tray process
holds the exclusive writer lock for its whole lifetime, at most one worker
runs at a time (``_busy``), state saves are atomic, and every Qt object is
touched exclusively in the main thread via a queued signal.  The GUI stays
responsive either way, and the reducer keeps a single, already tested
entry point.
"""

from __future__ import annotations

import sys
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Mapping

from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal
from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPalette, QPixmap
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon

try:  # QtDBus ships with PySide6 on Linux; other platforms may not have it.
    from PySide6.QtDBus import QDBusConnection, QDBusInterface, QDBusMessage
except ImportError:  # pragma: no cover - platform dependent
    QDBusConnection = QDBusInterface = QDBusMessage = None  # type: ignore[assignment]

from . import platform_backend
from .atcwatch import (
    ConfigConflictError,
    ConfigError,
    RunResult,
    SystemNotifier,
    deliver_pending,
    load_config_snapshot,
    logger,
    run_poll,
    setup_logging,
    update_sticky_notifications,
    utcnow,
)
from .core import (
    DEFAULTS,
    ICON_STATES,
    apply_away,
    backoff_active,
    consume_pending_while_paused,
    deliveries_held,
    due_at,
    format_away,
    format_ts,
    record_unexpected_failure,
    test_alert_message,
    tray_summary,
    try_parse_ts,
)
from .paths import Paths, package_version, resolve_paths
from .state import LockUnavailable, StateError, load_state, save_state, singleton_lock


TICK_MS = 30_000
PAUSE_MINUTES = 60
MENU_INFO_ROWS = 6  # up to five airport rows plus one "… and N more" line
ICON_DIR = Path(__file__).resolve().parent / "icons" / "png"

STICKY_MENU_TITLE = "Keep visible until dismissed"
# Which kinds get a checkbox, in menu order.  The radar positions live in
# their own submenu because they are one kind seen from three angles.
STICKY_TOP_ITEMS = (
    ("live", "Airport goes live"),
    ("lead", "Booking starts soon"),
)
STICKY_UPGRADE_ITEMS = (
    ("upgrade:APP", "Approach (APP)"),
    ("upgrade:DEP", "Departure (DEP)"),
    ("upgrade:CTR", "Center (CTR)"),
)
STICKY_TAIL_ITEMS = (
    ("cancelled", "Booking cancelled"),
    ("event", "New event"),
)
# The order selectors are written back in — stable, so a toggle produces a
# minimal diff in a file the user also edits by hand.
STICKY_SELECTOR_ORDER = tuple(
    selector for selector, _ in
    STICKY_TOP_ITEMS + STICKY_UPGRADE_ITEMS + STICKY_TAIL_ITEMS)
STICKY_PLAIN_SELECTORS = tuple(
    selector for selector, _ in STICKY_TOP_ITEMS + STICKY_TAIL_ITEMS)


def tint_pixmap(pixmap: QPixmap, color: QColor) -> QPixmap:
    """Recolour a monochrome *pixmap* to *color*, keeping its alpha shape.

    The Linux assets are pure black silhouettes; only their *shape* encodes the
    state.  Painting the colour through the alpha mask
    (``CompositionMode_SourceIn``) therefore loses no information and makes the
    icon visible on a dark panel.
    """

    ratio = pixmap.devicePixelRatio()
    tinted = QPixmap(pixmap.size())
    tinted.setDevicePixelRatio(1.0)  # paint in raw device pixels
    tinted.fill(Qt.GlobalColor.transparent)
    painter = QPainter(tinted)
    painter.drawPixmap(0, 0, pixmap)
    painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
    painter.fillRect(tinted.rect(), color)
    painter.end()
    tinted.setDevicePixelRatio(ratio)
    return tinted


def icon_foreground(palette: QPalette) -> QColor:
    """The colour a tray icon should take in the current theme."""

    return palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.WindowText)


def _load_icon(state_name: str, palette: QPalette | None = None) -> QIcon:
    template = platform_backend.icon_is_template()
    if template:
        # macOS: the template mask adapts to light/dark menu bars by itself
        # (plan 5.1).  QIcon picks up the committed @2x sibling automatically.
        icon = QIcon(str(ICON_DIR / "macos" / f"atcwatch-{state_name}.png"))
        icon.setIsMask(True)
        return icon

    # Linux has no template mechanism, and the assets are pure black — on a
    # dark panel they would be invisible.  Tint them with the theme foreground
    # at load time; the @2x asset is added as a second, dpr-tagged pixmap so
    # high-DPI panels keep the sharp variant.
    if palette is None:
        app = QApplication.instance()
        palette = app.palette() if app is not None else QPalette()
    color = icon_foreground(palette)
    icon = QIcon()
    for suffix, ratio in (("", 1.0), ("@2x", 2.0)):
        path = ICON_DIR / "linux" / f"atcwatch-{state_name}{suffix}.png"
        if not path.is_file():
            continue
        source = QPixmap(str(path))
        if source.isNull():
            continue
        source.setDevicePixelRatio(ratio)
        icon.addPixmap(tint_pixmap(source, color))
    icon.setIsMask(False)
    return icon


class AvailabilityProbe:
    """Answers one question per tick: can the user act on a popup right now?

    Two sources, both read fresh on every tick — no DBus signals.  Polling,
    not an ``ActiveChanged`` subscription, on purpose: it matches the
    wall-clock principle of the tick (plan 7.3).  Every tick re-reads the real
    state, so suspend/resume, a restarted screen locker, or a dropped bus
    connection heal by themselves instead of leaving the tray convinced the
    user is still away.  Each call costs about 3 ms.

    * ``org.freedesktop.ScreenSaver.GetActive`` — the locked screen.
    * the ``Inhibited`` property of ``org.freedesktop.Notifications`` — the
      "do not disturb" mode, which Plasma also sets while a fullscreen
      application (X-Plane) inhibits notifications.  Same situation as a
      locked screen: the popup is neither seen nor dismissable.

    The readings are folded into *one* boolean, because there is one away
    period regardless of how many reasons overlap.  ``away()`` returns ``None``
    when *no* source can be asked at all — no session bus, another desktop,
    macOS.  The caller then treats the user as present, which switches the
    whole feature off silently.  Every reason is logged exactly once, never
    per tick.
    """

    SCREENSAVER = ("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver")
    NOTIFICATIONS = (
        "org.freedesktop.Notifications", "/org/freedesktop/Notifications")
    PROPERTIES = "org.freedesktop.DBus.Properties"

    def __init__(self) -> None:
        self._screensaver: Any | None = None
        self._notifications: Any | None = None
        self._reported: set[str] = set()
        self._reasons: tuple[str, ...] = ()
        if QDBusConnection is None:
            self._explain("screen lock", "PySide6 was built without QtDBus")
            self._explain("do-not-disturb", "PySide6 was built without QtDBus")
            return
        bus = QDBusConnection.sessionBus()
        if not bus.isConnected():
            self._explain("screen lock", "no session bus")
            self._explain("do-not-disturb", "no session bus")
            return
        self._screensaver = self._connect(
            bus, "screen lock", *self.SCREENSAVER, self.SCREENSAVER[0])
        self._notifications = self._connect(
            bus, "do-not-disturb", *self.NOTIFICATIONS, self.PROPERTIES)

    def _connect(
        self, bus: Any, source: str, service: str, obj: str, interface: str,
    ) -> Any | None:
        handle = QDBusInterface(service, obj, interface, bus)
        if not handle.isValid():
            self._explain(source, f"{service} is not available")
            return None
        return handle

    def _explain(self, source: str, reason: str) -> None:
        if source in self._reported:
            return
        self._reported.add(source)
        logger.debug(
            "%s detection off (%s); it cannot hold notifications back",
            source, reason)

    def _unwrap(
        self, source: str, reply: Any, what: str,
    ) -> bool | None:
        """One DBus reply → ``True``/``False``/``None`` (cannot be asked)."""

        if (QDBusMessage is not None
                and reply.type() != QDBusMessage.MessageType.ReplyMessage):
            self._explain(source, f"{what} failed: {reply.errorMessage()}")
            return None
        arguments = reply.arguments()
        value = arguments[0] if arguments else None
        # A property read answers with a variant; PySide6 hands it over either
        # already unwrapped or as QDBusVariant, depending on the build.
        variant = getattr(value, "variant", None)
        if callable(variant):
            value = variant()
        if not isinstance(value, bool):
            self._explain(source, f"{what} returned an unexpected reply")
            return None
        return value

    def _locked(self) -> bool | None:
        if self._screensaver is None:
            return None
        return self._unwrap(
            "screen lock", self._screensaver.call("GetActive"), "GetActive")

    def _inhibited(self) -> bool | None:
        if self._notifications is None:
            return None
        reply = self._notifications.call(
            "Get", self.NOTIFICATIONS[0], "Inhibited")
        return self._unwrap("do-not-disturb", reply, "Get Inhibited")

    def away(self) -> bool | None:
        """``True`` if any source says so, ``None`` if none can be asked."""

        readings = {
            "screen locked": self._locked(),
            "notifications inhibited": self._inhibited(),
        }
        self._reasons = tuple(
            reason for reason, value in readings.items() if value)
        if self._reasons:
            return True
        if any(value is False for value in readings.values()):
            return False
        return None

    def reasons(self) -> str:
        """Why the last ``away()`` said yes — for the log line, not for logic."""

        return ", ".join(self._reasons) or "unavailable"


class _PollBridge(QObject):
    """Queued hand-over of a finished poll from the worker to the GUI thread."""

    finished = Signal(object, int)


class AtcwatchTray(QObject):
    """QSystemTrayIcon, durable QMenu, 30-second tick.  No domain logic."""

    def __init__(
        self,
        app: QApplication,
        paths: Paths,
        *,
        fixture_payload: Mapping[str, Any] | None = None,
        verbose: bool = False,
    ) -> None:
        super().__init__()
        self._app = app
        self._paths = paths
        self._fixture = fixture_payload
        self._verbose = verbose
        self._busy = False
        self._force_poll = False
        self._quitting = False
        self._generation = 0
        self._pause_request: str | None = None
        self._pause_requested = False
        self._notifier = SystemNotifier()
        self._availability = AvailabilityProbe()
        self._state: dict[str, Any] = load_state(
            self._paths.state, quarantine=False).state
        self._config = self._read_config()

        self._bridge = _PollBridge()
        self._bridge.finished.connect(self._on_poll_finished)

        self._icons: dict[str, QIcon] = {}
        self._reload_icons()
        self._tray = QSystemTrayIcon(self._icons["idle"])
        self._menu = QMenu()
        self._info_rows: list[QAction] = []
        for _ in range(MENU_INFO_ROWS):
            action = self._menu.addAction("")
            action.setEnabled(False)
            action.setVisible(False)
            # Connected once here, not per refresh (_refresh runs every 30 s):
            # the ICAO travels via QAction.setData, never via a fresh connect.
            action.triggered.connect(self._on_info_row_triggered)
            self._info_rows.append(action)
        self._menu.addSeparator()
        self._open_action = self._menu.addAction("Open My Airports")
        self._open_action.triggered.connect(self._on_open_action_triggered)
        self._check_action = self._menu.addAction("Check now")
        self._check_action.triggered.connect(self._check_now)
        self._test_alert_action = self._menu.addAction("Send test notification")
        self._test_alert_action.triggered.connect(self._send_test_alert)
        self._build_sticky_menu()
        self._pause_action = self._menu.addAction(
            f"Pause notifications for {PAUSE_MINUTES // 60} hour")
        self._pause_action.setCheckable(True)
        self._pause_action.triggered.connect(self._toggle_pause)
        self._menu.addSeparator()
        self._last_check_action = self._menu.addAction("Last check: never")
        self._last_check_action.setEnabled(False)
        # Static: the running package cannot change version under itself, so
        # this is set once here and never touched by _refresh.
        version_action = self._menu.addAction(f"atcwatch {package_version()}")
        version_action.setEnabled(False)
        quit_action = self._menu.addAction("Quit")
        quit_action.triggered.connect(self._quit)
        self._tray.setContextMenu(self._menu)
        if not platform_backend.icon_is_template():
            # Linux: a plain left click opens My Airports (plan 5.2).  On
            # macOS the status item opens the menu; its first active entry
            # is "Open My Airports".
            self._tray.activated.connect(self._on_activated)

        self._timer = QTimer(self)
        self._timer.setInterval(TICK_MS)
        self._timer.timeout.connect(self._tick)

        # Re-tint when the user switches between a light and a dark theme at
        # runtime.  Qt6 delivers ApplicationPaletteChange to the application
        # object; ThemeChange covers platform themes that swap without a new
        # palette.  Caveat: this follows the *application* palette.  If the
        # panel uses a different colour scheme than the Qt application (some
        # desktops style their tray independently), the icon follows the app.
        self._app.installEventFilter(self)

    def eventFilter(self, watched: QObject, event: QEvent) -> bool:
        if watched is self._app and event.type() in (
            QEvent.Type.ApplicationPaletteChange,
            QEvent.Type.ThemeChange,
        ):
            self._reload_icons()
            self._refresh(utcnow())
        return super().eventFilter(watched, event)

    def _reload_icons(self) -> None:
        palette = self._app.palette()
        self._icons = {
            name: _load_icon(name, palette) for name in ICON_STATES}

    # -- lifecycle ---------------------------------------------------------

    def start(self) -> None:
        self._tray.show()
        self._refresh(utcnow())
        self._timer.start()
        QTimer.singleShot(0, self._tick)

    def _quit(self) -> None:
        # Invalidate any in-flight worker generation; state saves are atomic,
        # so an interrupted cycle can never corrupt state.json.
        self._quitting = True
        self._generation += 1
        self._timer.stop()
        self._tray.hide()
        self._app.quit()

    # -- configuration -----------------------------------------------------

    def _read_config(self) -> dict[str, Any]:
        try:
            snapshot = load_config_snapshot(self._paths.config)
        except ConfigError:
            # run_poll reports the config error itself (error icon/outcome);
            # the summary projection keeps working on the previous values or
            # on the documented defaults.  The digest is dropped so the sticky
            # menu cannot offer to write over a file it could not read.
            self._config_digest = None
            previous = getattr(self, "_config", None)
            return dict(previous) if previous else dict(DEFAULTS)
        self._config_digest = snapshot.digest
        return snapshot.config

    # -- sticky notification settings --------------------------------------
    #
    # The tray owns exactly one config value, sticky_notifications, and writes
    # it atomically (see update_sticky_notifications).  Everything else in
    # config.json stays hand-edited.

    def _build_sticky_menu(self) -> None:
        try:
            self._sticky_supported = platform_backend.supports_sticky()
        except RuntimeError:  # pragma: no cover - tray needs a real platform
            self._sticky_supported = False
        title = (
            STICKY_MENU_TITLE if self._sticky_supported
            else f"{STICKY_MENU_TITLE} (Linux only)")
        # Built with an explicit parent instead of addMenu(title): that keeps
        # C++ ownership with the parent menu, so a later menu() lookup cannot
        # hand ownership to a temporary Python wrapper and destroy the
        # submenu (and its actions) when that wrapper is collected.
        self._sticky_menu = QMenu(title, self._menu)
        self._menu.addMenu(self._sticky_menu)
        self._sticky_actions: dict[str, QAction] = {}
        for selector, label in STICKY_TOP_ITEMS:
            self._add_sticky_action(self._sticky_menu, selector, label)
        self._upgrade_menu = QMenu("Radar upgrades", self._sticky_menu)
        self._sticky_menu.addMenu(self._upgrade_menu)
        for selector, label in STICKY_UPGRADE_ITEMS:
            self._add_sticky_action(self._upgrade_menu, selector, label)
        for selector, label in STICKY_TAIL_ITEMS:
            self._add_sticky_action(self._sticky_menu, selector, label)
        self._sticky_menu.addSeparator()
        # Not a choice, an explanation: a blind tool is a silent failure, so
        # health always waits and recovery never does (core._render).
        for text in ("Monitoring unhealthy (always visible)",
                     "Monitoring recovered (always brief)"):
            hint = self._sticky_menu.addAction(text)
            hint.setEnabled(False)
        # A hand edit between two ticks must not be overwritten by a click, so
        # the file is re-read the moment the menu opens rather than only after
        # a poll.
        self._menu.aboutToShow.connect(self._reload_config_for_menu)
        self._refresh_sticky_actions()

    def _add_sticky_action(
        self, menu: QMenu, selector: str, label: str,
    ) -> QAction:
        action = menu.addAction(label)
        action.setCheckable(True)
        action.setData(selector)
        action.triggered.connect(self._on_sticky_toggled)
        self._sticky_actions[selector] = action
        return action

    def _effective_sticky_selectors(self) -> set[str]:
        """What the checkboxes show for the configured selector list.

        A bare "upgrade" means every radar position, so it reads back as three
        ticks.  The first click then writes the positions out individually —
        that is what makes "all but DEP" expressible at all.
        """

        configured = set(self._config.get("sticky_notifications") or ())
        selected = {
            selector for selector in STICKY_PLAIN_SELECTORS
            if selector in configured}
        for selector, _ in STICKY_UPGRADE_ITEMS:
            if "upgrade" in configured or selector in configured:
                selected.add(selector)
        return selected

    def _on_sticky_toggled(self, checked: bool = False) -> None:
        action = self.sender()
        selector = action.data() if action is not None else None
        if not isinstance(selector, str):
            return
        if self._busy or not self._sticky_supported:
            # Disabling the parent menu action does not reach a submenu that
            # is already open on screen: its entries stay clickable.  So the
            # guard has to sit here as well, and the tick Qt already painted
            # has to be put back.
            self._refresh_sticky_actions()
            return
        selection = self._effective_sticky_selectors()
        if checked:
            selection.add(selector)
        else:
            selection.discard(selector)
        self._apply_sticky_selection(
            [item for item in STICKY_SELECTOR_ORDER if item in selection])

    def _apply_sticky_selection(self, selectors: list[str]) -> None:
        current = [
            selector for selector in STICKY_SELECTOR_ORDER
            if selector in self._effective_sticky_selectors()]
        if selectors == current:
            # Nothing to say: do not touch a file the user may have open in an
            # editor just to write back what it already means.
            return
        if self._config_digest is None:
            self._sticky_warning(
                "config.json cannot be read — fix it before changing this")
            return
        try:
            snapshot = update_sticky_notifications(
                self._paths.config, selectors,
                expected_digest=self._config_digest)
        except ConfigConflictError as exc:
            logger.warning("config.json changed outside atcwatch: %s", exc)
            self._config = self._read_config()
            self._sticky_warning(
                "config.json changed outside atcwatch — settings were "
                "reloaded; please try again")
            return
        except ConfigError as exc:
            logger.error("cannot save the notification setting: %s", exc)
            # A failure after the rename would leave the new file on disk with
            # a stale view in memory; re-reading costs nothing and makes the
            # menu show whatever actually survived.
            self._config = self._read_config()
            self._sticky_warning(f"Could not save the setting: {exc}")
            return
        self._config = snapshot.config
        self._config_digest = snapshot.digest
        logger.info("sticky notifications set to %s", selectors or "none")
        self._refresh(utcnow())

    def _sticky_warning(self, message: str) -> None:
        # _refresh puts every checkbox back on the file's actual content, so
        # the optimistic tick Qt already painted is reverted before the user
        # reads the balloon.
        self._refresh(utcnow())
        self._tray.showMessage(
            "ATCMatch Desktop Notifier", message,
            QSystemTrayIcon.MessageIcon.Warning)

    def _reload_config_for_menu(self) -> None:
        if self._busy:
            # The worker owns the cycle; its own reload lands in
            # _on_poll_finished a moment later.
            return
        self._config = self._read_config()
        self._refresh_sticky_actions()

    def _refresh_sticky_actions(self) -> None:
        selection = self._effective_sticky_selectors()
        for selector, action in self._sticky_actions.items():
            action.blockSignals(True)
            action.setChecked(selector in selection)
            action.blockSignals(False)
        self._sticky_menu.menuAction().setEnabled(
            self._sticky_supported
            and self._config_digest is not None
            and not self._busy)

    # -- polling -----------------------------------------------------------

    def _tick(self) -> None:
        if self._quitting:
            return
        now = utcnow()
        if not self._busy:
            # Only when no worker owns the state file; otherwise the next tick
            # (30 s later) picks the transition up — the same rule as pause.
            self._sync_availability(now)
        self._refresh(now)
        if self._busy:
            return
        last_attempt = try_parse_ts(self._state.get("last_attempt"))
        if last_attempt is not None and last_attempt > now:
            # Wall clock jumped backwards (plan 7.3): clamp, warn, carry on.
            logger.warning(
                "clock went backwards (last attempt %s, now %s); clamping",
                format_ts(last_attempt), format_ts(now))
            self._state["last_attempt"] = format_ts(now)
        due = due_at(self._state, self._config, now)
        force = self._force_poll and not backoff_active(self._state, now)
        if force or due is None or now >= due:
            self._start_poll()

    def _sync_availability(self, now: datetime) -> None:
        """Track whether the user can act and, on return, deliver the backlog."""

        state, away_for = apply_away(
            self._state, self._availability.away(), now)
        changed = state.get("away_since") != self._state.get("away_since")
        self._state = state
        if away_for is None:
            if changed:
                logger.info("user is away at %s (%s); holding notifications",
                            self._state["away_since"],
                            self._availability.reasons())
                self._persist()
            return
        logger.info("user is back after %s", format_away(away_for))
        paused_until = try_parse_ts(self._state.get("paused_until"))
        if paused_until is not None and paused_until > now:
            # Pause wins over away: whoever silences the notifier wants quiet,
            # not a catch-up salvo the moment the screen comes back.  Pause
            # discards, away collects — so the backlog is consumed, exactly as
            # every other cycle does while paused.
            self._state = consume_pending_while_paused(self._state, now)
            logger.info("notifications are paused; away backlog discarded")
            self._persist()
            return
        held = len(self._state.get("pending_notifications", {}))
        if not held:
            self._persist()
            return

        def deliver(notification: Any) -> bool:
            return self._notifier.notify(
                notification.title, notification.body, notification.urgent,
                warning=notification.kind == "health")

        state, delivered, error = deliver_pending(
            self._paths, self._state, notifier=deliver, now=now,
            away_for=away_for)
        self._state = dict(state)
        if error:
            logger.error("cannot persist the away digest: %s", error)
        else:
            logger.info(
                "delivered %d of %d notifications held while away",
                len(delivered), held)

    def _persist(self) -> None:
        try:
            save_state(self._paths.state, self._state)
        except StateError as exc:
            logger.error("cannot persist state: %s", exc)

    def _start_poll(self) -> None:
        self._busy = True
        # Immediate feedback: no second "Check now" while a poll is running
        # (the next _refresh keeps it disabled via _busy).
        self._check_action.setEnabled(False)
        # Same reason, plus one of its own: run_poll reads the config at the
        # start of its cycle, so a change accepted now would not match the
        # notifications this cycle is about to produce.
        self._sticky_menu.menuAction().setEnabled(False)
        self._force_poll = False
        self._generation += 1
        generation = self._generation
        paths = self._paths
        fixture = self._fixture
        notifier = self._notifier

        def deliver(notification: Any) -> bool:
            return notifier.notify(
                notification.title, notification.body, notification.urgent,
                warning=notification.kind == "health")

        # Away: the cycle still runs and still persists every event, but its
        # outbox is not emptied.  The held items are delivered as one labelled
        # digest when the user is available again (_sync_availability).
        sink = None if deliveries_held(self._state) else deliver

        def work() -> None:
            try:
                result: object = run_poll(
                    paths, now=utcnow(), fixture_payload=fixture,
                    notifier=sink)
            except Exception as exc:  # never die silently (plan item 5)
                result = exc
            self._bridge.finished.emit(result, generation)

        threading.Thread(
            target=work, name="atcwatch-poll", daemon=True).start()

    def _on_poll_finished(self, result: object, generation: int) -> None:
        if generation != self._generation or self._quitting:
            return
        self._busy = False
        if isinstance(result, RunResult):
            self._state = dict(result.state)
            self._config = self._read_config()
            logger.info(
                "poll finished: %s %s",
                result.outcome, result.detail or "")
            # Same diagnosis trail as the CLI (_print_result): without it a
            # daemon run says only "fresh" and leaves no record of what the
            # payload contained or what was decided -- exactly what is needed
            # afterwards when an expected alarm never appeared.
            for line in result.log:
                logger.debug("decision %s", line)
            for notification in result.notifications:
                logger.debug(
                    "notification %s %s: %s",
                    notification.kind, notification.key, notification.title)
        else:
            # In-memory only: the unexpected failure becomes visible as the
            # error icon and tooltip instead of a silent death.  Recording
            # the attempt keeps ``due_at`` pacing intact — without it every
            # 30-second tick would retry (and hammer the server).
            self._state = record_unexpected_failure(
                self._state, f"unexpected error: {result}", utcnow())
            logger.error("atcwatch poll failed unexpectedly: %s", result)
        if self._pause_requested:
            target, self._pause_request = self._pause_request, None
            self._pause_requested = False
            self._apply_pause(target)
        self._refresh(utcnow())

    # -- actions -----------------------------------------------------------

    def _check_now(self) -> None:
        # A deliberate wish, but an active 403/429 gate stays binding
        # (plan 7.3/8.3); the action is disabled during backoff anyway.
        self._force_poll = True
        self._tick()

    def _send_test_alert(self) -> None:
        # Diagnostic, not a domain event: the notification pause silences
        # airport alarms, but a deliberately clicked test must always try to
        # deliver — the user wants to verify the delivery path right now.
        # No dedup-ledger entry, no state write.
        title, body = test_alert_message(utcnow())
        if self._notifier.notify(title, body):
            return
        # A failed test is not an operational error: icon and tooltip stay
        # untouched.  The Qt balloon is a separate delivery channel, so it
        # can still surface the failure even when notify-send/DBus is broken.
        message = "Test alert could not be delivered — check notify-send/DBus"
        logger.warning(message)
        self._tray.showMessage(
            "ATCMatch Desktop Notifier", message,
            QSystemTrayIcon.MessageIcon.Warning)

    def _open_my_airports(self, icao: str | None = None) -> None:
        url = self._config.get("url")
        if not url:
            return
        target = f"{url}/?tab=my-airports"
        if icao:
            target += f"&focus={icao}"
        try:
            opened = platform_backend.open_url(target)
        except (ValueError, OSError) as exc:
            logger.warning("cannot open browser: %s", exc)
            return
        if not opened:
            # open_url already logged the handler's own message; the balloon
            # makes the failure visible without touching the operational icon.
            self._tray.showMessage(
                "ATCMatch Desktop Notifier",
                "Could not open the browser — see the atcwatch log",
                QSystemTrayIcon.MessageIcon.Warning)

    def _on_open_action_triggered(self, _checked: bool = False) -> None:
        # Qt's triggered signal always passes a bool "checked" argument; drop
        # it explicitly so it can never be mistaken for an ICAO.
        self._open_my_airports()

    def _on_info_row_triggered(self, _checked: bool = False) -> None:
        action = self.sender()
        icao = action.data() if action is not None else None
        if isinstance(icao, str) and icao:
            self._open_my_airports(icao)

    def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
        if reason == QSystemTrayIcon.ActivationReason.Trigger:
            self._open_my_airports()

    def _toggle_pause(self) -> None:
        now = utcnow()
        paused_until = try_parse_ts(self._state.get("paused_until"))
        currently_paused = paused_until is not None and paused_until > now
        target = (
            None if currently_paused
            else format_ts(now + timedelta(minutes=PAUSE_MINUTES)))
        if self._busy:
            # The worker owns the state file right now; apply after it lands.
            # Revert the visual toggle Qt applied on click — the checkbox
            # only flips once the pause is really applied (_refresh).
            self._pause_action.setChecked(currently_paused)
            self._pause_request = target
            self._pause_requested = True
            return
        self._apply_pause(target)

    def _apply_pause(self, target: str | None) -> None:
        self._state["paused_until"] = target
        try:
            save_state(self._paths.state, self._state)
        except StateError as exc:
            logger.error("cannot persist pause: %s", exc)
        self._refresh(utcnow())

    # -- presentation ------------------------------------------------------

    def _refresh(self, now: datetime) -> None:
        icon_state, tooltip, rows = tray_summary(self._state, self._config, now)
        self._tray.setIcon(self._icons[icon_state])
        self._tray.setToolTip(tooltip)
        url = self._config.get("url")
        for index, action in enumerate(self._info_rows):
            if index < len(rows):
                row = rows[index]
                action.setText(row.text)
                action.setData(row.icao)
                action.setEnabled(bool(row.icao) and bool(url))
                action.setVisible(True)
            else:
                action.setData(None)
                action.setVisible(False)

        last_fresh = try_parse_ts(self._state.get("last_fresh_data"))
        if last_fresh:
            minutes = max(0, int((now - last_fresh).total_seconds() // 60))
            hours, rest = divmod(minutes, 60)
            age = f"{hours} h {rest} min ago" if hours else f"{minutes} min ago"
            self._last_check_action.setText(
                f"Last check: {last_fresh.astimezone(timezone.utc):%H:%M}z ({age})")
        else:
            self._last_check_action.setText("Last check: never")

        next_try = try_parse_ts(self._state.get("next_try"))
        if backoff_active(self._state, now) and next_try is not None:
            remaining = max(1, int((next_try - now).total_seconds() // 60))
            self._check_action.setText(f"Check now (blocked {remaining} min)")
            self._check_action.setEnabled(False)
        else:
            self._check_action.setText("Check now")
            self._check_action.setEnabled(not self._busy)

        paused_until = try_parse_ts(self._state.get("paused_until"))
        paused = paused_until is not None and paused_until > now
        self._pause_action.setChecked(paused)
        self._pause_action.setText(
            "Resume notifications" if paused
            else f"Pause notifications for {PAUSE_MINUTES // 60} hour")
        self._open_action.setEnabled(bool(url))
        last_outcome = self._state.get("last_outcome")
        needs_setup = last_outcome == "watchlist_missing" or (
            last_outcome in {"fresh", "empty"}
            and not self._state.get("airports")
        )
        self._open_action.setText(
            "Set up My Airports…" if needs_setup else "Open My Airports")
        self._refresh_sticky_actions()


def main(
    *,
    paths: Paths | None = None,
    verbose: bool = False,
    fixture_payload: Mapping[str, Any] | None = None,
) -> int:
    paths = paths or resolve_paths()
    setup_logging(paths, verbose)
    lock = singleton_lock(paths.lock)
    try:
        lock.__enter__()
    except LockUnavailable:
        logger.error(
            "atcwatch is already running — look for the airplane icon in "
            "your status bar")
        return 2
    try:
        app = QApplication.instance() or QApplication(sys.argv[:1])
        app.setQuitOnLastWindowClosed(False)
        if not QSystemTrayIcon.isSystemTrayAvailable():
            logger.error(
                "no system tray is available in this session; "
                "use --once/--status instead")
            return 1
        tray = AtcwatchTray(
            app, paths, fixture_payload=fixture_payload, verbose=verbose)
        tray.start()
        return int(app.exec())
    finally:
        lock.__exit__(None, None, None)
