"""Headless atcwatch orchestration and command line interface."""

from __future__ import annotations

import argparse
from copy import deepcopy
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import json
import logging
from logging.handlers import RotatingFileHandler
import os
from pathlib import Path
import re
import sys
import time
from typing import Any, Callable, Mapping, Sequence

from .core import (
    DEFAULTS,
    DIGEST_THRESHOLD,
    DOMAIN_KINDS,
    EVENT_NOTIFY_HORIZON_DAYS,
    RADAR_POSITIONS,
    Decision,
    FlightOrder,
    Notification,
    PayloadValidationError,
    acknowledge_delivery,
    backoff_active,
    begin_attempt,
    cleanup_removed_airports,
    format_ts,
    freshness,
    parse_flight_order,
    parse_ts,
    pending_notifications,
    process_fresh_snapshot,
    rebind_server_origin,
    record_failure,
    record_local_error,
    record_stale,
    render_digest,
    summarize,
    test_alert_failure_hint,
    test_alert_message,
    validate_payload,
)
from .paths import Paths, package_version, resolve_paths
from . import platform_backend
from .state import (
    AtomicWriteConflict,
    LockUnavailable,
    StateError,
    atomic_write_private_file,
    fresh_state,
    load_state,
    save_state,
    singleton_lock,
)
from .transport import (
    FetchError,
    TransportConfigError,
    UrllibTransport,
    normalize_base_url,
    parse_retry_after,
)


ICAO_RE = re.compile(r"^[A-Z0-9]{4}$")
MAX_JSON_BYTES = 2 * 1024 * 1024
ALLOWED_CONFIG_FIELDS = {"url", *DEFAULTS}
# The real watchlist chips (plan 8.1/8.5); anything else is a broken file.
WATCHLIST_HOURS = frozenset({6, 12, 24, 168})


# -- logging -----------------------------------------------------------------
#
# stderr always works (journal under systemd, terminal under a manual run);
# a rotating file under the state directory is the fallback for a manual
# tray start with no terminal attached at all (KRunner, Plasma autostart,
# double-click).  Human CLI results (--once/--status/--test-alert) stay
# plain prints on stdout — that is program output, not diagnostics.

LOGGER_NAME = "atcwatch"
LOG_FORMAT = "%(asctime)sZ %(levelname)s %(message)s"
LOG_DATEFMT = "%Y-%m-%dT%H:%M:%S"


class _DynamicStderrHandler(logging.StreamHandler):
    """Console handler that re-reads sys.stderr on every emit.

    A plain StreamHandler binds its stream once, at construction.  Under
    pytest's capsys (which swaps sys.stderr per test) that would silently
    write into an already-detached stream.  Re-reading sys.stderr per
    record keeps this correct under pytest and under a real terminal or
    systemd redirect alike.
    """

    def __init__(self) -> None:
        super().__init__(stream=sys.stderr)

    def emit(self, record: logging.LogRecord) -> None:
        self.stream = sys.stderr
        super().emit(record)


def _make_formatter() -> logging.Formatter:
    formatter = logging.Formatter(LOG_FORMAT, LOG_DATEFMT)
    formatter.converter = time.gmtime
    return formatter


_log_file_warned: set[str] = set()


def setup_logging(paths: Paths | None, verbose: bool) -> logging.Logger:
    """Configure the ``atcwatch`` logger: console always, rotating file if possible.

    Idempotent — safe to call on every CLI invocation and every tray start:
    handlers are identified by name and replaced rather than piled up. A
    file that cannot be opened (read-only filesystem, missing permissions)
    is not fatal: one warning on stderr, then console-only logging.
    """

    logger = logging.getLogger(LOGGER_NAME)
    logger.setLevel(logging.DEBUG if verbose else logging.INFO)
    logger.propagate = False
    formatter = _make_formatter()

    console = next(
        (h for h in logger.handlers if getattr(h, "name", None) == "console"), None)
    if console is None:
        console = _DynamicStderrHandler()
        console.name = "console"
        logger.addHandler(console)
    console.setFormatter(formatter)

    if paths is not None:
        target = os.path.abspath(str(paths.log))
        existing = next(
            (h for h in logger.handlers if getattr(h, "name", None) == "file"), None)
        if existing is not None and existing.baseFilename != target:
            logger.removeHandler(existing)
            existing.close()
            existing = None
        if existing is not None:
            existing.setFormatter(formatter)
        else:
            try:
                paths.log.parent.mkdir(parents=True, exist_ok=True)
                handler = RotatingFileHandler(
                    paths.log, maxBytes=1_000_000, backupCount=3,
                    encoding="utf-8")
            except OSError as exc:
                if target not in _log_file_warned:
                    print(
                        f"atcwatch: cannot open log file {paths.log}: {exc}; "
                        "logging to console only", file=sys.stderr)
                    _log_file_warned.add(target)
            else:
                handler.name = "file"
                handler.setFormatter(formatter)
                logger.addHandler(handler)
    return logger


# Baseline console-only logging so any module that imports this file (e.g.
# tray.py constructing AtcwatchTray directly in tests, without going
# through main()) always has a working "atcwatch" logger.  main() calls
# setup_logging() again with the resolved paths and --verbose.
logger = setup_logging(None, False)


class ConfigError(ValueError):
    pass


class ConfigWriteError(ConfigError):
    """config.json could not be updated — the file on disk is unchanged."""


class ConfigConflictError(ConfigWriteError):
    """config.json was edited elsewhere; the caller must reload and retry."""


class WatchlistError(ValueError):
    pass


class WatchlistMissingError(WatchlistError):
    """watchlist.json does not exist yet — quiet by design (plan 8.2)."""


@dataclass(frozen=True)
class Watchlist:
    icaos: tuple[str, ...]
    hours: int
    written_at: datetime | None
    rev: int | None
    # The optional flight order rides along in the same file (plan 6, step 1).
    # A rejected order is carried as a reason, not raised: it must not stop the
    # airport watch, which is what the file is primarily for.
    flight: FlightOrder | None = None
    flight_error: str | None = None


@dataclass(frozen=True)
class RunResult:
    outcome: str
    state: Mapping[str, Any]
    notifications: tuple[Notification, ...]
    delivered: tuple[str, ...]
    summary: Mapping[str, Any]
    detail: str = ""
    request_count: int = 0
    exit_code: int = 0
    log: tuple[str, ...] = ()


Notifier = Callable[[Notification], bool]


class SystemNotifier:
    """Dependency-free desktop notification seam for CLI and tray."""

    def notify(
        self, title: str, body: str, sticky: bool = False, *,
        warning: bool = False,
    ) -> bool:
        return platform_backend.notify(title, body, sticky, warning=warning)


def utcnow() -> datetime:
    return datetime.now(timezone.utc).replace(second=0, microsecond=0)


def _read_json(path: Path, label: str) -> Any:
    try:
        size = path.stat().st_size
    except FileNotFoundError as exc:
        raise ValueError(f"{label} does not exist: {path}") from exc
    except OSError as exc:
        raise ValueError(f"cannot inspect {label} {path}: {exc}") from exc
    if size > MAX_JSON_BYTES:
        raise ValueError(f"{label} exceeds {MAX_JSON_BYTES} bytes")
    try:
        with path.open("r", encoding="utf-8") as handle:
            return json.load(handle)
    except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise ValueError(f"cannot read {label} {path}: {exc}") from exc


def _integer(value: Any, name: str, minimum: int, maximum: int) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
        raise ConfigError(f"{name} must be an integer from {minimum} to {maximum}")
    return value


@dataclass(frozen=True)
class ConfigSnapshot:
    """A validated config plus the exact bytes it was parsed from.

    The tray needs all three: *config* to work with, *text* to patch without
    reformatting the user's file, and *digest* to notice an edit that happened
    in the meantime.  The digest is content-only on purpose — an mtime would
    report a conflict for a no-op editor save and block a legitimate click.
    """

    config: dict[str, Any]
    text: str
    digest: str


def _digest(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def _read_config_text(path: Path) -> str:
    try:
        size = path.stat().st_size
    except FileNotFoundError as exc:
        raise ConfigError(f"config.json does not exist: {path}") from exc
    except OSError as exc:
        raise ConfigError(f"cannot inspect config.json {path}: {exc}") from exc
    if size > MAX_JSON_BYTES:
        raise ConfigError(f"config.json exceeds {MAX_JSON_BYTES} bytes")
    try:
        # newline="": no universal-newline translation.  The text has to be
        # the file's actual content — a CRLF config would otherwise arrive as
        # LF, and every digest taken from it would mismatch the bytes on disk.
        with path.open("r", encoding="utf-8", newline="") as handle:
            return handle.read()
    except (OSError, UnicodeDecodeError) as exc:
        raise ConfigError(f"cannot read config.json {path}: {exc}") from exc


def load_config_snapshot(path: Path) -> ConfigSnapshot:
    text = _read_config_text(path)
    try:
        raw = json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError(f"cannot read config.json {path}: {exc}") from exc
    return ConfigSnapshot(config=_validated_config(raw), text=text,
                          digest=_digest(text))


def load_config(path: Path) -> dict[str, Any]:
    return load_config_snapshot(path).config


def _validated_config(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        raise ConfigError("config.json root must be an object")
    unknown = set(raw) - ALLOWED_CONFIG_FIELDS
    if unknown:
        raise ConfigError(f"unknown config fields: {sorted(unknown)}")
    if "api_key" in raw:
        raise ConfigError("api_key must never be stored in atcwatch config")
    url = raw.get("url")
    try:
        base_url, _ = normalize_base_url(url)
    except TransportConfigError as exc:
        raise ConfigError(str(exc)) from exc
    config = {**DEFAULTS, **raw, "url": base_url}
    config["poll_min"] = _integer(config["poll_min"], "poll_min", 5, 1440)
    config["notify_lead_min"] = _integer(
        config["notify_lead_min"], "notify_lead_min", 1, 1440)
    if not isinstance(config["notify_upgrades"], bool):
        raise ConfigError("notify_upgrades must be a boolean")
    timeout = config["timeout_sec"]
    if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 1 <= timeout <= 60:
        raise ConfigError("timeout_sec must be a number from 1 to 60")
    config["timeout_sec"] = float(timeout)
    config["stale_max_min"] = _integer(
        config["stale_max_min"], "stale_max_min", 5, 180)
    config["offline_cooldown_min"] = _integer(
        config["offline_cooldown_min"], "offline_cooldown_min", 10, 1440)
    config["sticky_notifications"] = _sticky_notifications(
        config["sticky_notifications"])
    config["flight_apply_cmd"] = _flight_apply_cmd(config["flight_apply_cmd"])
    return config


def _flight_apply_cmd(value: Any) -> list[str]:
    """Validate the argv for "To the Sim" — a list of non-empty strings.

    An empty list is the normal case: the action is simply not configured.
    Anything else is rejected rather than repaired, because the repaired
    version would start a process nobody wrote down.
    """

    if not isinstance(value, list) or any(
            not isinstance(item, str) for item in value):
        raise ConfigError("flight_apply_cmd must be a list of strings")
    if any(not item.strip() for item in value):
        raise ConfigError("flight_apply_cmd must not contain empty arguments")
    return list(value)


def _sticky_notifications(value: Any) -> list[str]:
    """Validate the sticky selectors, rejecting anything unrecognised.

    An entry that silently does nothing is worse than an error: the user would
    believe a message waits for them and only find out by missing it.  Hence no
    lenient parsing, no case folding of positions, and no acceptance of the
    operational kinds — health always sticks, recovery never does, and neither
    is negotiable.
    """

    if not isinstance(value, list) or any(
            not isinstance(item, str) for item in value):
        raise ConfigError("sticky_notifications must be a list of strings")
    allowed = sorted(DOMAIN_KINDS) + [
        f"upgrade:{position}" for position in sorted(RADAR_POSITIONS)]
    unknown = [item for item in value if item not in allowed]
    if unknown:
        raise ConfigError(
            f"unknown sticky_notifications entries: {sorted(unknown)} "
            f"(allowed: {allowed})")
    return list(dict.fromkeys(value))


# -- writing one config value back -------------------------------------------
#
# config.json belongs to the user: hand-written, hand-ordered, and the README
# tells people to edit it.  Re-serialising it with json.dump would silently
# reorder the keys and re-indent the file every time someone ticks a menu box.
# So the tray patches exactly the one value it owns and leaves every other
# byte alone.  The text is known-good JSON by the time these run — it was
# parsed and validated first — so the scanners may assume well-formed input.


def _scan_string(text: str, index: int) -> int:
    """Index just past the JSON string starting at *index*."""

    index += 1
    while index < len(text):
        char = text[index]
        if char == "\\":
            index += 2
            continue
        if char == '"':
            return index + 1
        index += 1
    raise ConfigWriteError("unterminated string in config.json")


def _scan_value(text: str, index: int) -> int:
    """Index just past the JSON value starting at *index*."""

    char = text[index]
    if char == '"':
        return _scan_string(text, index)
    if char in "{[":
        depth = 0
        while index < len(text):
            current = text[index]
            if current == '"':
                index = _scan_string(text, index)
                continue
            if current in "{[":
                depth += 1
            elif current in "}]":
                depth -= 1
                if depth == 0:
                    return index + 1
            index += 1
        raise ConfigWriteError("unterminated object or array in config.json")
    end = index
    while end < len(text) and text[end] not in ",}] \t\r\n":
        end += 1
    return end


def _top_level_value_span(text: str, key: str) -> tuple[int, int] | None:
    """Where the value of top-level *key* sits, or None when it is absent.

    On a duplicated key this reports the *last* one, because that is the one
    ``json.loads`` keeps — patching the first would leave the file parsing to
    the old value and the click would appear to do nothing.
    """

    found: tuple[int, int] | None = None
    index = 0
    while index < len(text) and text[index].isspace():
        index += 1
    if index >= len(text) or text[index] != "{":
        raise ConfigWriteError("config.json root must be an object")
    index += 1
    while index < len(text):
        char = text[index]
        if char.isspace() or char == ",":
            index += 1
            continue
        if char == "}":
            return found
        if char != '"':
            raise ConfigWriteError("unexpected token in config.json")
        name_end = _scan_string(text, index)
        name = json.loads(text[index:name_end])
        colon = name_end
        while colon < len(text) and text[colon].isspace():
            colon += 1
        if colon >= len(text) or text[colon] != ":":
            raise ConfigWriteError("malformed member in config.json")
        value_start = colon + 1
        while value_start < len(text) and text[value_start].isspace():
            value_start += 1
        value_end = _scan_value(text, value_start)
        if name == key:
            found = (value_start, value_end)
        index = value_end
    raise ConfigWriteError("unterminated object in config.json")


def _member_indent(text: str) -> str:
    for line in text.splitlines()[1:]:
        stripped = line.strip()
        if stripped and not stripped.startswith("}"):
            return line[: len(line) - len(line.lstrip())]
    return "  "


def _insert_top_level_member(text: str, key: str, rendered: str) -> str:
    """Append a new top-level member, matching the file's own layout."""

    body = text.rstrip()
    if not body.endswith("}"):
        raise ConfigWriteError("config.json root must be an object")
    trailer = text[len(body):]
    head = body[:-1].rstrip()
    newline = "\r\n" if "\r\n" in text else "\n"
    separator = "" if head.endswith("{") else ","
    member = f'{_member_indent(text)}{json.dumps(key)}: {rendered}'
    return f"{head}{separator}{newline}{member}{newline}}}{trailer}"


def update_sticky_notifications(
    path: Path, selectors: Sequence[str], *, expected_digest: str,
) -> ConfigSnapshot:
    """Write *selectors* into config.json and return the resulting snapshot.

    Refuses to write when the file changed since *expected_digest* was taken,
    which is what makes a parallel hand edit survive in practice.  Two honest
    limits: the final compare and the rename are not one atomic step, so an
    edit landing inside that last window still loses; and an error *after* the
    rename (chmod, directory fsync) reports failure even though the new
    content is already on disk.  Everything before the rename leaves the file
    exactly as it was.
    """

    wanted = _sticky_notifications(list(selectors))
    snapshot = load_config_snapshot(path)
    if snapshot.digest != expected_digest:
        raise ConfigConflictError(f"config.json changed on disk: {path}")
    if path.is_symlink():
        # os.replace would break the link and leave the real file behind.
        raise ConfigWriteError(
            f"config.json is a symlink; edit it by hand: {path}")
    rendered = json.dumps(wanted, ensure_ascii=False)
    span = _top_level_value_span(snapshot.text, "sticky_notifications")
    if span is None:
        updated = _insert_top_level_member(
            snapshot.text, "sticky_notifications", rendered)
    else:
        updated = snapshot.text[:span[0]] + rendered + snapshot.text[span[1]:]
    try:
        verified = _validated_config(json.loads(updated))
    except (ConfigError, json.JSONDecodeError) as exc:
        raise ConfigWriteError(
            f"refusing to write an invalid config.json: {exc}") from exc
    if verified["sticky_notifications"] != wanted:
        raise ConfigWriteError("sticky_notifications update did not take effect")
    try:
        atomic_write_private_file(
            path, updated.encode("utf-8"),
            expected_content=snapshot.text.encode("utf-8"))
    except AtomicWriteConflict as exc:
        raise ConfigConflictError(str(exc)) from exc
    except OSError as exc:
        raise ConfigWriteError(f"cannot write config.json {path}: {exc}") from exc
    return ConfigSnapshot(
        config=verified, text=updated, digest=_digest(updated))


def load_watchlist(path: Path) -> Watchlist:
    if not path.exists():
        raise WatchlistMissingError(f"watchlist.json does not exist: {path}")
    try:
        raw = _read_json(path, "watchlist.json")
    except ValueError as exc:
        raise WatchlistError(str(exc)) from exc
    if not isinstance(raw, dict):
        raise WatchlistError("watchlist.json root must be an object")
    icaos = raw.get("icaos")
    if not isinstance(icaos, list) or len(icaos) > 50:
        raise WatchlistError("icaos must be an array with at most 50 entries")
    if any(not isinstance(icao, str) or not ICAO_RE.fullmatch(icao) for icao in icaos):
        raise WatchlistError("every ICAO must be four uppercase letters or digits")
    if len(set(icaos)) != len(icaos):
        raise WatchlistError("icaos must not contain duplicates")
    hours = raw.get("hours")
    if (isinstance(hours, bool) or not isinstance(hours, int)
            or hours not in WATCHLIST_HOURS):
        raise WatchlistError(
            "hours must be one of "
            + ", ".join(str(value) for value in sorted(WATCHLIST_HOURS)))
    written_at = None
    if "written_at" in raw:
        try:
            written_at = parse_ts(raw["written_at"])
        except PayloadValidationError as exc:
            raise WatchlistError(f"invalid written_at: {exc}") from exc
    rev = raw.get("rev")
    if rev is not None and (isinstance(rev, bool) or not isinstance(rev, int) or rev < 0):
        raise WatchlistError("rev must be a non-negative integer")
    flight, flight_error = parse_flight_order(raw.get("flight"))
    if flight_error:
        logger.warning("flight order rejected: %s", flight_error)
    return Watchlist(tuple(icaos), hours, written_at, rev, flight, flight_error)


def _session_cookie(state: Mapping[str, Any], configured_origin: str) -> str | None:
    if state.get("session_cookie_origin") != configured_origin:
        return None
    cookie = state.get("session_cookie")
    return cookie if isinstance(cookie, str) else None


def _apply_session(
    state: Mapping[str, Any], *, cookie: str | None,
    configured_origin: str, changed: bool, now: datetime,
) -> dict[str, Any]:
    result = deepcopy(state)
    result["session_cookie"] = cookie
    result["session_cookie_origin"] = configured_origin if cookie else None
    if changed:
        result["session_cookie_at"] = format_ts(now) if cookie else None
    return result


def _transport_outcome(error: FetchError) -> str:
    if error.status == 403:
        return "forbidden"
    if error.status == 429:
        return "rate_limited"
    if error.status in {400, 422}:
        return "invalid_request"
    if error.status == 401:
        return "auth"
    return error.kind


def _commit_and_deliver(
    paths: Paths,
    decision: Decision,
    *,
    dry_run: bool,
    notifier: Notifier | None,
    now: datetime,
    allowed_kinds: set[str],
    away_for: timedelta | None = None,
) -> tuple[dict[str, Any], tuple[str, ...], str | None]:
    state = decision.state
    if dry_run:
        return state, (), None

    # At-most-once (plan 6.3 no. 6): every notification that is about to be
    # sent is acknowledged and persisted BEFORE notify().  A crash after this
    # save loses the popup — the documented price — but can never produce a
    # duplicate alarm after restart.
    deliverable = (
        [
            notification for notification in pending_notifications(state)
            if notification.kind in allowed_kinds
        ]
        if notifier is not None else []
    )
    acknowledged = state
    for notification in deliverable:
        acknowledged = acknowledge_delivery(acknowledged, notification.id, now)
    try:
        save_state(paths.state, acknowledged)
    except StateError as exc:
        # Nothing acknowledged reached the disk: send nothing.
        return state, (), str(exc)
    if not deliverable:
        return acknowledged, (), None

    def send(notification: Notification) -> bool:
        try:
            delivered = bool(notifier(notification))
        except Exception as exc:
            logger.error(
                "notification backend raised for %s %s: %s",
                notification.kind, notification.key, exc)
            delivered = False
        if not delivered:
            # Backend failure: the attempt stays consumed (at-most-once,
            # plan 8.2) and is only logged — never retried every cycle.
            logger.warning(
                "notification backend failed for %s %s; dropped",
                notification.kind, notification.key)
        return delivered

    delivered: list[str] = []
    domain = [n for n in deliverable if n.kind in DOMAIN_KINDS]
    operational = [n for n in deliverable if n.kind not in DOMAIN_KINDS]

    # Coming back from a locked screen, everything that piled up belongs in
    # one labelled catch-up popup — but a "collection" of a single event is
    # nonsense, so exactly one held item is delivered as its normal alarm.
    bundle = len(domain) > (1 if away_for is not None else DIGEST_THRESHOLD)
    if bundle:
        # One summary notification instead of a popup salvo (plan 2.4/6.2),
        # e.g. after suspend/resume caught up on several transitions at once.
        title, body, urgent = render_digest(domain, away_for=away_for)
        digest = Notification(
            id="digest", key="digest", kind="digest", icao=None,
            title=title, body=body, urgent=urgent, created_at=now, data={})
        if send(digest):
            delivered.extend(notification.id for notification in domain)
    else:
        for notification in domain:
            if send(notification):
                delivered.append(notification.id)
    for notification in operational:
        if send(notification):
            delivered.append(notification.id)
    return acknowledged, tuple(delivered), None


DELIVERABLE_KINDS = {
    "live", "lead", "upgrade", "cancelled", "event", "health", "recovery",
}


def deliver_pending(
    paths: Paths,
    state: Mapping[str, Any],
    *,
    notifier: Notifier,
    now: datetime,
    away_for: timedelta | None = None,
) -> tuple[dict[str, Any], tuple[str, ...], str | None]:
    """Deliver whatever waits in the outbox, without polling the server.

    Used when the user comes back to a locked screen: the events were already
    reduced and persisted during the absence, only their delivery was held.
    At-most-once still holds — ``_commit_and_deliver`` acknowledges and saves
    before it sends.
    """

    decision = Decision(
        dict(state), pending_notifications(state), summarize(state, None))
    return _commit_and_deliver(
        paths, decision, dry_run=False, notifier=notifier, now=now,
        allowed_kinds=DELIVERABLE_KINDS, away_for=away_for)


def _finish(
    paths: Paths,
    decision: Decision,
    *,
    outcome: str,
    detail: str,
    request_count: int,
    dry_run: bool,
    notifier: Notifier | None,
    now: datetime,
    allowed_kinds: set[str],
    exit_code: int,
) -> RunResult:
    final_state, delivered, save_error = _commit_and_deliver(
        paths, decision, dry_run=dry_run, notifier=notifier, now=now,
        allowed_kinds=allowed_kinds)
    if save_error:
        return RunResult(
            "state_error", final_state, (), delivered, summarize(final_state, None),
            detail=save_error, request_count=request_count, exit_code=1,
            log=decision.log)
    return RunResult(
        outcome, final_state, pending_notifications(final_state), delivered,
        decision.summary, detail=detail, request_count=request_count,
        exit_code=exit_code, log=decision.log)


def _empty_watchlist(state: Mapping[str, Any], now: datetime) -> Decision:
    result = cleanup_removed_airports(state, ())
    # A deliberately empty, valid watchlist is a healthy outcome (plan §4):
    # it ends the health window like a fresh payload does — an old error
    # anchor must not survive hours of healthy emptiness and then turn a
    # single later blip into an instant health warning.
    result.update({
        "seeded": True,
        "last_attempt": format_ts(now),
        "transport_error_since": None,
        "stale_since": None,
        "local_error_since": None,
        "fail_count": 0,
        "first_fail_at": None,
        "last_outcome": "empty",
        "last_error": None,
        "next_try": None,
    })
    return Decision(result, pending_notifications(result), summarize(result, None))


def run_poll(
    paths: Paths,
    *,
    now: datetime,
    dry_run: bool = False,
    fixture_payload: Mapping[str, Any] | None = None,
    opener: Any | None = None,
    notifier: Notifier | None = None,
) -> RunResult:
    """Run one headless cycle.  Dry-run is fully filesystem read-only."""

    if now.tzinfo is None:
        raise ValueError("now must be timezone-aware")
    now = now.astimezone(timezone.utc).replace(second=0, microsecond=0)
    try:
        loaded = load_state(paths.state, quarantine=not dry_run)
        state = loaded.state
    except StateError as exc:
        state = fresh_state()
        return RunResult(
            "state_error", state, (), (), summarize(state, None),
            detail=str(exc), exit_code=1)

    try:
        config = load_config(paths.config)
    except ConfigError as exc:
        attempted = begin_attempt(state, now)
        decision = record_local_error(
            attempted, outcome="config_error", detail=str(exc), now=now)
        return _finish(
            paths, decision, outcome="config_error", detail=str(exc),
            request_count=0, dry_run=dry_run, notifier=notifier, now=now,
            allowed_kinds={"health"}, exit_code=1)

    # A changed server origin resets backoff, failure counters, and the old
    # cookie before the backoff gate — a 403 sperre of the old server must
    # not gag the new one (plan 6.3: state binds to the origin).
    state = rebind_server_origin(state, config["url"])

    if backoff_active(state, now):
        return RunResult(
            "backoff", state, (), (), summarize(state, None),
            detail=f"next try at {state['next_try']}", exit_code=1)

    try:
        watchlist = load_watchlist(paths.watchlist)
    except WatchlistMissingError as exc:
        attempted = begin_attempt(state, now)
        decision = record_local_error(
            attempted, outcome="watchlist_missing", detail=str(exc), now=now,
            health_window=False)
        return _finish(
            paths, decision, outcome="watchlist_missing", detail=str(exc),
            request_count=0, dry_run=dry_run, notifier=notifier, now=now,
            allowed_kinds=set(), exit_code=1)
    except WatchlistError as exc:
        attempted = begin_attempt(state, now)
        decision = record_local_error(
            attempted, outcome="watchlist_error", detail=str(exc), now=now)
        return _finish(
            paths, decision, outcome="watchlist_error", detail=str(exc),
            request_count=0, dry_run=dry_run, notifier=notifier, now=now,
            allowed_kinds={"health"}, exit_code=1)

    if not watchlist.icaos:
        return _finish(
            paths, _empty_watchlist(state, now), outcome="empty", detail="",
            request_count=0, dry_run=dry_run, notifier=notifier, now=now,
            allowed_kinds=set(), exit_code=0)

    working = cleanup_removed_airports(begin_attempt(state, now), watchlist.icaos)
    request_count = 0
    if fixture_payload is None:
        configured_origin = config["url"]
        transport = UrllibTransport(opener=opener)
        try:
            fetched = transport.fetch_status(
                base_url=config["url"],
                icaos=list(watchlist.icaos),
                hours=watchlist.hours,
                event_horizon_days=EVENT_NOTIFY_HORIZON_DAYS,
                session_cookie=_session_cookie(working, configured_origin),
                now=now,
                timeout=config["timeout_sec"],
            )
        except FetchError as exc:
            request_count = getattr(transport, "request_count", 0)
            working = _apply_session(
                working, cookie=exc.session_cookie,
                configured_origin=configured_origin,
                changed=exc.session_cookie_changed, now=now)
            outcome = _transport_outcome(exc)
            retry_seconds = parse_retry_after(exc.retry_after, now)
            retry_after_at = (
                now + timedelta(seconds=retry_seconds)
                if retry_seconds is not None else None)
            decision = record_failure(
                working, outcome=outcome, detail=exc.detail,
                now=now, cfg=config,
                http_success_contact=exc.kind == "payload",
                retry_after_at=retry_after_at)
            return _finish(
                paths, decision, outcome=outcome, detail=exc.detail,
                request_count=request_count, dry_run=dry_run, notifier=notifier,
                now=now, allowed_kinds={"health"}, exit_code=1)
        request_count = getattr(transport, "request_count", 0)
        working = _apply_session(
            working, cookie=fetched.session_cookie,
            configured_origin=configured_origin,
            changed=fetched.session_cookie_changed, now=now)
        payload = fetched.payload
    else:
        payload = deepcopy(fixture_payload)

    try:
        snapshot = validate_payload(payload, watchlist.icaos)
    except PayloadValidationError as exc:
        decision = record_failure(
            working, outcome="protocol", detail=str(exc), now=now, cfg=config,
            http_success_contact=True)
        return _finish(
            paths, decision, outcome="protocol", detail=str(exc),
            request_count=request_count, dry_run=dry_run, notifier=notifier,
            now=now, allowed_kinds={"health"}, exit_code=1)

    fresh, stale_detail = freshness(snapshot, now, int(config["stale_max_min"]))
    if not fresh:
        decision = record_stale(
            working, detail=stale_detail, now=now, cfg=config)
        return _finish(
            paths, decision, outcome="stale", detail=stale_detail,
            request_count=request_count, dry_run=dry_run, notifier=notifier,
            now=now, allowed_kinds={"health"}, exit_code=1)

    decision = process_fresh_snapshot(
        working, snapshot, watchlist.icaos, config, now)
    return _finish(
        paths, decision, outcome="fresh", detail="",
        request_count=request_count, dry_run=dry_run, notifier=notifier,
        now=now,
        allowed_kinds={
            "live", "lead", "upgrade", "cancelled", "event", "recovery",
        },
        exit_code=0)


def _print_result(result: RunResult) -> None:
    print(f"{result.outcome}: atcwatch cycle")
    print(
        "airports="
        f"live:{result.summary.get('live', 0)} "
        f"upcoming:{result.summary.get('upcoming', 0)} "
        f"unknown:{result.summary.get('unknown', 0)}")
    print(f"pending_notifications={len(result.notifications)}")
    if result.detail:
        print(f"detail={result.detail}")
    logger.debug("requests=%d", result.request_count)
    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)


def load_fixture(value: str) -> Any:
    """Read a fixture payload; bare names resolve to the bundled fixtures."""

    fixture_path = Path(value)
    if not fixture_path.is_absolute() and not fixture_path.exists():
        bundled = Path(__file__).with_name("fixtures") / fixture_path
        if bundled.exists():
            fixture_path = bundled
    return _read_json(fixture_path, "fixture")


def cmd_once(args: argparse.Namespace, paths: Paths) -> int:
    now = parse_ts(args.now) if args.now else utcnow()
    fixture = None
    if args.fixture:
        try:
            fixture = load_fixture(args.fixture)
        except ValueError as exc:
            logger.error(str(exc))
            return 1

    def invoke() -> RunResult:
        backend = SystemNotifier()
        deliver = (
            None if args.dry_run
            else lambda notification: backend.notify(
                notification.title, notification.body, notification.urgent,
                warning=notification.kind == "health")
        )
        return run_poll(
            paths, now=now, dry_run=args.dry_run,
            fixture_payload=fixture, notifier=deliver)

    if args.dry_run:
        result = invoke()
    else:
        try:
            with singleton_lock(paths.lock):
                result = invoke()
        except LockUnavailable as exc:
            logger.error(str(exc))
            return 2
    _print_result(result)
    return result.exit_code


def cmd_status(args: argparse.Namespace, paths: Paths) -> int:
    loaded = load_state(paths.state, quarantine=False)
    state = loaded.state
    try:
        watchlist = load_watchlist(paths.watchlist)
        watchlist_text = str(len(watchlist.icaos))
    except WatchlistError as exc:
        watchlist_text = f"error: {exc}"
    try:
        mtime = datetime.fromtimestamp(
            paths.watchlist.stat().st_mtime, tz=timezone.utc)
        watchlist_mtime = format_ts(mtime)
    except OSError:
        watchlist_mtime = "missing"
    print(f"state={paths.state}")
    print(f"watchlist={paths.watchlist}")
    print(f"watchlist_mtime={watchlist_mtime}")
    print(f"log={paths.log}")
    print(f"seeded={str(bool(state.get('seeded'))).lower()}")
    outcome = state.get("last_outcome")
    outcome_text = "never polled" if outcome == "new" else outcome
    print(f"last_outcome={outcome_text}")
    print(f"last_fresh_data: {state.get('last_fresh_data') or 'never'}")
    print(f"fail_count={state.get('fail_count', 0)}")
    print(f"next_try={state.get('next_try') or 'none'}")
    print(f"watchlist_airports={watchlist_text}")
    print(f"ledger_entries={len(state.get('fired', {}))}")
    print(f"pending_notifications={len(state.get('pending_notifications', {}))}")
    print(f"autostart_hint={platform_backend.autostart_hint()}")
    if loaded.warning:
        logger.warning(loaded.warning)
    # "new" is the state right after installation, before the first poll ran —
    # that is the normal starting point, not a failure (see tray_summary's
    # "idle" icon for the same outcome). Only "fresh"/"empty" (a completed
    # poll) and "new" (no poll yet) count as healthy.
    healthy = outcome in {"fresh", "empty", "new"} and loaded.warning is None
    return 0 if healthy else 1


def cmd_reset(paths: Paths) -> int:
    try:
        with singleton_lock(paths.lock):
            try:
                paths.state.unlink()
            except FileNotFoundError:
                pass
            if paths.state.parent.exists():
                directory_fd = os.open(
                    paths.state.parent,
                    os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
                )
                try:
                    os.fsync(directory_fd)
                finally:
                    os.close(directory_fd)
    except LockUnavailable as exc:
        logger.error(str(exc))
        return 2
    except OSError as exc:
        logger.error("cannot reset state: %s", exc)
        return 1
    print(f"state reset: {paths.state}")
    return 0


def cmd_test_alert() -> int:
    """Send one diagnostic notification over the real delivery path.

    Deliberately needs no config, watchlist, network, lock, or state: the
    command only proves that desktop notifications reach this session
    (notify-send/DBus on Linux, osascript on macOS).
    """

    title, body = test_alert_message(utcnow())
    if platform_backend.notify(title, body):
        print("test alert delivered")
        return 0
    logger.error(test_alert_failure_hint(platform_backend.detect_platform()))
    return 1


def cmd_tray(args: argparse.Namespace, paths: Paths) -> int:
    """Lazy delegation point; only ``tray.py`` may import PySide6."""

    fixture = None
    if args.fixture:
        if not os.environ.get("ATCWATCH_HOME"):
            # The tray persists state; a fixture session against the real
            # state.json would contaminate it.  Refuse instead of silently
            # reinterpreting (project rule).
            logger.error(
                "--fixture for the tray requires ATCWATCH_HOME to point at a "
                "throwaway directory; refusing to write the real state.json")
            return 2
        try:
            fixture = load_fixture(args.fixture)
        except ValueError as exc:
            logger.error(str(exc))
            return 1
    try:
        from . import tray  # type: ignore[attr-defined]
    except (ImportError, ModuleNotFoundError) as exc:
        logger.error(
            "no tray frontend available: PySide6 is not installed — install "
            "it with \"pip install 'PySide6-Essentials>=6.9,<6.10'\" or use "
            "--once/--status")
        logger.debug("lazy tray import failed: %s", exc)
        return 2
    tray_main = getattr(tray, "main", None)
    if not callable(tray_main):
        logger.error("atcwatch tray module has no main() entry point")
        return 1
    return int(tray_main(
        paths=paths, verbose=args.verbose, fixture_payload=fixture) or 0)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="atcwatch")
    parser.add_argument(
        "--version", action="version",
        version=f"atcwatch {package_version()}",
        help="show the installed version and exit")
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument("--once", action="store_true", help="run one headless poll")
    mode.add_argument("--status", action="store_true", help="show state without a request")
    mode.add_argument("--reset-state", action="store_true", help="delete state under the writer lock")
    mode.add_argument("--test-alert", action="store_true",
                      help="send one desktop test notification and exit")
    parser.add_argument("--dry-run", action="store_true", help="do not lock, write, or notify")
    parser.add_argument("--fixture", metavar="PATH", help="read API payload from JSON")
    parser.add_argument("--now", metavar="UTC", help="fixture-only injected YYYY-MM-DDTHH:MMZ")
    parser.add_argument("--verbose", action="store_true")
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(list(argv) if argv is not None else None)
    if args.now and not args.fixture:
        parser.error("--now is allowed only together with --fixture")
    if args.now and not args.once:
        parser.error("--now requires --once; the tray runs on wall-clock time")
    if args.fixture and (args.status or args.reset_state or args.test_alert):
        parser.error("--fixture requires --once or the tray mode")
    if args.dry_run and not args.once:
        parser.error("--dry-run requires --once")

    if args.test_alert:
        # Pure delivery diagnosis: no paths, config, watchlist, lock, or
        # state — including no log file; console-only logging.
        setup_logging(None, args.verbose)
        return cmd_test_alert()
    try:
        paths = resolve_paths()
    except (ValueError, RuntimeError) as exc:
        setup_logging(None, args.verbose)
        logger.error(str(exc))
        return 1
    setup_logging(paths, args.verbose)
    if args.once:
        return cmd_once(args, paths)
    if args.status:
        return cmd_status(args, paths)
    if args.reset_state:
        return cmd_reset(paths)
    return cmd_tray(args, paths)


if __name__ == "__main__":
    raise SystemExit(main())
