#!/usr/bin/env python3
"""Apply an ATCMatch flight order to a running X-Plane 12.

This is the reference implementation behind atcwatch's ``flight_apply_cmd``
(the **To the Sim** menu entry).  atcwatch is messenger and click surface; this
script is the only link in the chain that talks to the simulator, and so it is
the only one that may know about gates, ``.acf`` paths and the sim's network
API.

It is deliberately small.  It sets the flight and reports in one line what it
did -- and, just as importantly, what it did *not* do.  It never guesses: an
order it cannot apply honestly is refused with a reason rather than applied
half-way and reported as success.

Requirements: X-Plane 12 with *Settings > Network > Accept incoming
connections* switched on (it is **off** by default).  No plugin, no third-party
package -- Python standard library only.

Measured against X-Plane 12.4.3 on 2026-08-25:
  * A start needs BOTH keys.  ``{"ramp_start": {"airport_id": "EDDF"}}`` is
    answered with 400 json_invalid, "It needs both an 'airport_id' and a
    'ramp' key" -- hence the apt.dat lookup below.
  * There is no endpoint that lists an airport's stands (v3 knows /flight and
    /datarefs; /airports and friends are 404), so the scenery is the only
    source for the name.
  * A stand name that does not exist is refused loudly: 400 missing_ramp in
    27 ms.  Nothing is placed anywhere, so this script does not need to
    second-guess the name it sends.
  * Finding a stand took 0.00-0.28 s when an installed scenery had the
    airport, and 1.6-2.1 s when the 380 MB Global Airports file had to be
    streamed.  That is why there is no cache.
  * Loading is what dominates everything else: staying at the same airport is
    a matter of seconds, building a new world takes 2 to 5 minutes, and for
    all of that time the web API -- served from the same main loop -- does not
    answer.  Every timeout in this file is written with that in mind: none of
    them is long enough to wait a load out, and none of them is allowed to
    call a delivered flight a failure.

Exit codes (atcwatch shows stdout either way, and treats non-zero as failure):
    0  flight set.  Aircraft and livery are read back and compared -- unless
       the flight was posted from the start screen, where the world is still
       loading and there is nothing yet to read; the output line says which
       of the two happened.
    1  refused -- the order, the configuration or the aircraft map is wrong
    2  the simulator is not ready (not running, hung, or paused)
    3  the simulator rejected the flight, or the read-back disagreed

Exit 0 therefore covers both "set and read back" and "posted while the sim was
building a world".  That is on purpose and stays that way: atcwatch reads the
exit code alone, and a separate code for "unconfirmed" would report failures
for flights that are sitting in the sim.  A caller that needs the finer answer
asks for it with ``--json`` and reads ``confirmed``.

For being driven by another program rather than by a menu click:
    --wait SEC       keep asking while X-Plane is still coming up
    --expect-id ID   apply only this order, not whatever the file holds now
    --json           report the outcome as fields instead of a sentence
"""

from __future__ import annotations

import argparse
import json
import math
import os
import socket
import sqlite3
import struct
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

# Loopback, always, and never from the order.  The order is written by a web
# page from a server; letting it name a host or port would turn server-supplied
# JSON into a target selector for writes into a local process.
HOST = "127.0.0.1"
UDP_PORT = 49000
API = f"http://{HOST}:8086/api"

CLOCK_DREF = "sim/time/total_running_time_sec"
ACF_DREF = "sim/aircraft/view/acf_relative_path"
LIVERY_DREF = "sim/aircraft/view/acf_livery_path"
NET_HINT = "enable Settings > Network > Accept incoming connections"

HTTP_TIMEOUT = 5.0
# Der Flug-POST bekommt mehr Luft als eine Abfrage. X-Plane antwortet zwar
# normalerweise sofort und laedt erst danach -- aber wenn es beim Eintreffen
# schon baut, schweigt es, und zwar nicht kurz: ein Flug am selben Platz ist
# in Sekunden da, eine neue Welt dauert gemessen 2 bis 5 Minuten. Diese Frist
# ist deshalb kein Wartemittel, sondern nur die Kulanz fuer eine kurze Spitze.
POST_TIMEOUT = 20.0
UDP_TIMEOUT = 1.0

REQUIRED = ("id", "icao", "dest_icao", "aircraft_code", "livery_name")


class Refused(Exception):
    """The order cannot be applied honestly.  Carries its own exit code."""

    def __init__(self, message: str, code: int = 1) -> None:
        super().__init__(message)
        self.code = code


class SimBusy(Refused):
    """X-Plane is there but did not answer in time.

    Almost always means it is loading a world: the web API is served from the
    same main loop that builds the scenery, so it goes quiet for minutes at a
    time.  Not a fault, and -- for a request that was already delivered --
    not a reason to call anything failed.
    """

    def __init__(self, message: str) -> None:
        super().__init__(message, code=2)


# -- the order ----------------------------------------------------------------

def default_watchlist() -> Path:
    """Where atcwatch keeps the file, by its own documented rule."""

    override = os.environ.get("ATCWATCH_HOME")
    base = Path(override).expanduser() if override else Path.home() / "atcwatch"
    return base / "watchlist.json"


def read_order(path: Path) -> dict[str, str]:
    """The ``flight`` member, or a refusal naming what is wrong with it.

    Same required fields as ``atcwatch.core.parse_flight_order``.  Re-checked
    here rather than imported: this script must run without atcwatch installed,
    and an order that reached the disk by another route deserves the same
    scrutiny.
    """

    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        raise Refused(f"no watchlist at {path} -- plan a flight and press To the Sim")
    except (OSError, ValueError) as exc:
        raise Refused(f"cannot read {path}: {exc}")
    if not isinstance(raw, dict):
        raise Refused(f"{path}: top level must be an object")
    flight = raw.get("flight")
    if flight is None:
        raise Refused("no flight planned -- press To the Sim in ATCMatch first")
    if not isinstance(flight, dict):
        raise Refused("flight must be an object")
    order = {}
    for field in REQUIRED:
        value = flight.get(field)
        if not isinstance(value, str) or not value.strip():
            raise Refused(f"flight.{field} is missing")
        order[field] = value.strip()
    for field in ("airline", "fltnum", "reg"):
        value = flight.get(field)
        order[field] = value.strip() if isinstance(value, str) else ""
    return order


# -- talking to the sim -------------------------------------------------------

def _api(path: str, *, data: bytes | None = None,
         timeout: float | None = None) -> tuple[int, bytes]:
    """One call to the web API.  Raises :class:`Refused` if it cannot be made.

    A read is retried once.  X-Plane serves this API from its own main loop, so
    a busy moment -- loading, a frame hitch -- can swallow a request that the
    very next one answers in a fraction of a millisecond.  Writes are not
    retried: a POST that timed out may well have arrived.
    """

    req = urllib.request.Request(
        f"{API}{path}", data=data,
        headers={"Content-Type": "application/json",
                 "User-Agent": "ATCMatch-xp-flight/1.0"},
        method="POST" if data is not None else "GET")
    frist = HTTP_TIMEOUT if timeout is None else timeout
    versuche = 1 if data is not None else 2
    for versuch in range(versuche):
        try:
            with urllib.request.urlopen(req, timeout=frist) as resp:
                return resp.status, resp.read()
        except urllib.error.HTTPError as exc:
            return exc.code, exc.read()
        except (urllib.error.URLError, OSError) as exc:
            # "Es antwortet nicht" und "es ist nicht da" sind verschiedene
            # Dinge, und der Netzwerk-Hinweis passt nur zum zweiten. Ihn
            # trotzdem zu nennen schickt den Nutzer an einen Schalter, der
            # laengst richtig steht.
            grund = getattr(exc, "reason", exc)
            haengt = isinstance(grund, TimeoutError) or "timed out" in str(grund)
            if haengt and versuch + 1 < versuche:
                continue
            if haengt:
                wie_oft = "twice" if versuche > 1 else "once"
                raise SimBusy(
                    f"X-Plane did not answer within {frist:.0f} s ({wie_oft}) "
                    f"-- it is loading or otherwise busy")
            raise Refused(f"nothing answers on {HOST}:8086 ({grund}) -- "
                          f"is X-Plane running, and {NET_HINT}?", code=2)
    raise AssertionError("unreachable")


def udp_clock_is_growing() -> tuple[bool, str]:
    """Is the sim actually running, or only answering?

    ``/api/capabilities`` replies in a fraction of a millisecond even while the
    sim is wedged, so HTTP alone cannot tell "ready" from "hung".  UDP goes
    silent in that state, which makes a short RREF subscription the honest
    test.  A clock that answers but does not grow means paused or stuck.
    """

    dref = CLOCK_DREF.encode()
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.settimeout(UDP_TIMEOUT)

    def subscribe(freq: int) -> None:
        sock.sendto(b"RREF\x00" + struct.pack("<ii", freq, 1)
                    + dref.ljust(400, b"\x00"), (HOST, UDP_PORT))

    first: float | None = None
    try:
        subscribe(2)
        for _ in range(8):
            try:
                data, _addr = sock.recvfrom(2048)
            except socket.timeout:
                return False, (f"no answer from X-Plane on UDP {UDP_PORT} -- "
                               f"it is not running, or {NET_HINT}")
            if not data.startswith(b"RREF"):
                continue
            body = data[5:]
            for i in range(0, len(body) - 7, 8):
                idx, value = struct.unpack("<if", body[i:i + 8])
                if idx != 1:
                    continue
                if first is None:
                    first = value
                elif value > first:
                    return True, ""
        seen = f"{first:.1f}" if first is not None else "?"
        return False, f"X-Plane's clock stands at {seen} s -- it is paused or hung"
    finally:
        try:
            # Without this X-Plane keeps sending to a port nobody listens on.
            subscribe(0)
        finally:
            sock.close()


# -- waiting for a sim that is still coming up --------------------------------
#
# One question is the right amount when a person just pressed a menu item: the
# sim is either there or it is not, and eight seconds of silence is an answer.
# It is the wrong amount for a caller that starts this script *while* X-Plane
# boots -- flight_apply_cmd does exactly that, and after a fresh start the sim
# is unreachable for minutes.  So the waiting is an option, never a default:
# whoever waits has to say for how long, and atcwatch's own 120 s budget
# (FLIGHT_APPLY_TIMEOUT_SEC) stays the ceiling it always was.

RETRY_PAUSE = 2.0


def wait_for_sim(seconds: float) -> tuple[bool, bool, str]:
    """``(ready, starting, why)`` -- ask again until the sim answers.

    A world under construction counts as "not yet", not as a fault: with a
    deadline in hand, that is precisely what is being waited for.
    """

    # Die Frist wird zwischen den Versuchen geprueft, nicht mitten in einem:
    # ein laufender Versuch wird zu Ende gefragt. Er kostet bis zu 8 s UDP plus
    # eine HTTP-Frage, um so viel kann eine Wartefrist also ueberzogen werden.
    # Wer hart begrenzen muss, begrenzt den Prozess -- so wie atcwatch es mit
    # seinen 120 s ohnehin tut.
    ende = time.monotonic() + seconds
    while True:
        ready, why = udp_clock_is_growing()
        if ready:
            return True, False, ""
        try:
            if in_start_screen():
                return False, True, ""
        except SimBusy as busy:
            why = str(busy)
        except Refused:
            pass
        if time.monotonic() >= ende:
            return False, False, why
        time.sleep(min(RETRY_PAUSE, max(0.0, ende - time.monotonic())))


def in_start_screen() -> bool:
    """True while X-Plane sits in its menu with no world loaded.

    There the clock dataref does not exist yet (HTTP 404) and UDP is silent by
    construction -- so the readiness test above would call it "not running".
    It is ready, though: the flight POST is exactly what loads a world.
    """

    status, _body = _api(f"/v3/datarefs?filter[name]={CLOCK_DREF}")
    return status == 404


def read_dataref(name: str) -> str | None:
    """A string dataref's current value, or None if it cannot be read."""

    status, body = _api(f"/v3/datarefs?filter[name]={name}")
    if status != 200:
        return None
    try:
        entries = json.loads(body).get("data") or []
        ref_id = entries[0]["id"]
    except (ValueError, LookupError, TypeError):
        return None
    status, body = _api(f"/v3/datarefs/{ref_id}/value")
    if status != 200:
        return None
    try:
        value = json.loads(body)["data"]
    except (ValueError, LookupError, TypeError):
        return None
    if isinstance(value, str):
        import base64
        try:
            return base64.b64decode(value).split(b"\x00")[0].decode("utf-8")
        except (ValueError, UnicodeDecodeError):
            return None
    return None


def set_dref_udp(name: str, value: float) -> None:
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.sendto(b"DREF\x00" + struct.pack("<f", value)
                    + name.encode().ljust(500, b"\x00"), (HOST, UDP_PORT))
    finally:
        sock.close()


# -- the local hangar ---------------------------------------------------------
#
# ATCMatch names an aircraft by its `aircraft_code` (A319, B738, LU737, EJET).
# That is ATCMatch's own vocabulary, not a standard, and it is *declared* in
# the livery database rather than scanned off a disk -- so it cannot be turned
# into a directory on someone else's installation without guessing.  Guessing
# would put the wrong aeroplane in the sim and report success, so instead the
# user states the mapping once, in a file this script only reads.
#
# No mapping is the normal case: the flight is still set, and the output line
# says plainly that model and livery were left alone.

def load_config(path: Path) -> dict:
    if not path.exists():
        return {}
    try:
        cfg = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError) as exc:
        raise Refused(f"cannot read {path}: {exc}")
    if not isinstance(cfg, dict):
        raise Refused(f"{path}: top level must be an object")
    return cfg


# -- the hangar, read from its own database -----------------------------------
#
# aircraft-map.json exists because a stranger's machine has no livery database.
# The machine that *built* the hangar has one, and a generated copy of it can
# only be older than its source: an aeroplane is installed, the map is not
# rerun, and this script then reports "no entry" for something the sim owns.
# So `liveries_db` in config.json wins whenever it is set -- one source, no
# regeneration step, and no way for the answer to drift from whatever else
# reads that same database.
#
# A configured path that does not open is refused rather than quietly swapped
# for the map: a database that moved is worth being told about, and the stale
# map would answer *almost* right, which is the worse failure.

def acf_from_db(db_path: Path, code: str, livery: str,
                ) -> tuple[Path | None, Path | None, str]:
    """``(.acf, liveries folder, reason)`` for this paint, out of liveries.db.

    Both paths come from the database, the folder included: it is spelled
    ``Liveries`` for some add-ons and ``liveries`` for others, and guessing the
    spelling costs the paint while the flight still looks set.

    ``(None, None, reason)`` when the hangar cannot name a model: unknown code,
    unknown paint, or a family folder whose models the type does not tell
    apart.  That is the same "nothing to say" case aircraft-map.json has -- not
    an error, and never a guess.
    """

    try:
        con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
    except sqlite3.Error as exc:
        raise Refused(f"cannot open liveries_db {db_path}: {exc}")
    con.row_factory = sqlite3.Row
    try:
        aeroplane = con.execute(
            "SELECT liveries_path, icao_type FROM aircraft WHERE aircraft_code = ?",
            (code,)).fetchone()
        paint = con.execute(
            "SELECT icao_variant FROM livery "
            "WHERE aircraft_code = ? AND livery_name = ?", (code, livery)).fetchone()
    except sqlite3.Error as exc:
        raise Refused(f"cannot read liveries_db {db_path}: {exc}")
    finally:
        con.close()
    if aeroplane is None:
        return None, None, f"model/livery not set: {db_path.name} does not know {code}"
    if paint is None:
        return None, None, (f"model/livery not set: {db_path.name} knows {code} "
                            f"but not the paint {livery!r}")

    # The type is a property of the paint first and of the folder second: a
    # LevelUp 737 folder is one code covering four models, and which one a
    # paint belongs to is written on the paint.
    typ = paint["icao_variant"] or aeroplane["icao_type"] or code
    bemalungen = Path(aeroplane["liveries_path"]).expanduser()
    base = bemalungen.parent
    models = sorted(base.glob("*.acf"))
    if not models:
        return None, None, f"model/livery not set: no .acf in {base}"
    if len(models) == 1:
        return models[0], bemalungen, ""
    named = [m for m in models if typ.lower() in m.stem.lower()]
    if len(named) == 1:
        # The paint's own name may name a different model in the same folder:
        # "Lineage 1000 D-AWOW" sits next to Lineage.acf and carries the
        # variant E190.  Where name and variant disagree, neither is trusted --
        # a missing answer costs a click, a wrong one flies.
        andere = [m for m in models
                  if m != named[0] and m.stem.lower() in livery.lower()]
        if andere:
            return None, None, (
                f"model/livery not set: {livery!r} goes by {typ}, but its name "
                f"points at {andere[0].name} -- name and variant disagree")
        return named[0], bemalungen, ""
    pool = named or models
    return None, None, (f"model/livery not set: {len(pool)} models in {base.name} "
                        f"go by {typ} ({', '.join(m.name for m in pool)})")


def acf_relative(acf: Path, root: Path) -> str:
    """The path the API wants: relative to the X-Plane folder, symlinks and all.

    Aircraft/ is often a symlink onto another disk, and then the file resolves
    outside the folder while X-Plane still addresses it from within.
    """

    try:
        return acf.relative_to(root).as_posix()
    except ValueError:
        pass
    real = acf.resolve()
    try:
        return real.relative_to(root.resolve()).as_posix()
    except ValueError:
        pass
    for child in sorted(root.iterdir()):
        if not child.is_symlink():
            continue
        try:
            rest = real.relative_to(child.resolve())
        except (ValueError, OSError):
            continue
        return (Path(child.name) / rest).as_posix()
    raise Refused(f"{acf} is not under the X-Plane folder {root}")


def resolve_aircraft(order: dict[str, str], amap: dict, root: str | None,
                     db: Path | None = None,
                     ) -> tuple[dict[str, str] | None, str]:
    """``(aircraft payload, note)`` for this order.

    Returns ``(None, reason)`` when the hangar has nothing to say -- that is
    not an error.  Raises :class:`Refused` when the hangar *does* claim to
    know the aircraft but the claim does not hold on disk: a stale entry must
    not quietly become "flight without aircraft".
    """

    code = order["aircraft_code"]
    bemalungen: Path | None = None
    if db is not None:
        if not root:
            raise Refused("liveries_db is set, but xplane_root is not set in "
                          "config.json -- the database names the .acf by its "
                          "own path, and the API wants it relative to the "
                          "X-Plane folder")
        model, bemalungen, warum = acf_from_db(db, code, order["livery_name"])
        if model is None:
            return None, warum
        rel = acf_relative(model, Path(root).expanduser())
    else:
        # A family package (one folder, several models) cannot be resolved from
        # the code alone -- LevelUp's LU737 covers the 736, 737, 738 and 739,
        # and which one a paint belongs to is a property of the paint.  So a
        # specific "code/livery" key wins over the plain code, and the plain
        # code stays the right answer for every folder that holds exactly one
        # model.
        entry = amap.get(f"{code}/{order['livery_name']}", amap.get(code))
        if entry is None:
            return None, (f"model/livery not set ({code} / "
                          f"{order['livery_name']}): no entry in aircraft-map.json")
        if not isinstance(entry, str) or not entry.strip():
            raise Refused(f"aircraft-map.json: the entry for {code} must be a "
                          f"non-empty path relative to the X-Plane folder")
        rel = entry.strip()
        if not root:
            raise Refused(f"aircraft-map.json names {code}, but xplane_root is "
                          f"not set in config.json -- without it this script "
                          f"cannot check that the aircraft and the livery are "
                          f"installed")
    acf = Path(root).expanduser() / rel
    if not acf.is_file():
        woher = "liveries_db" if db is not None else "aircraft-map.json"
        raise Refused(f"{code} is mapped to {rel}, but that file does not "
                      f"exist under {root} -- fix {woher} or install "
                      f"the aircraft")
    livery = order["livery_name"]
    # With a database the folder is known exactly; without one, "liveries" is
    # the only spelling the map can imply.
    livery_dir = (bemalungen if db is not None else acf.parent / "liveries") / livery
    if not livery_dir.is_dir():
        # The aeroplane is there, the paint is not.  Worth setting the model
        # anyway and naming the missing folder: that is exactly what the user
        # has to download.
        return ({"path": rel},
                f"livery not set: {acf.parent.name}/liveries/{livery} is not installed")
    return {"path": rel, "livery": livery}, ""


# -- the parking stand --------------------------------------------------------
#
# X-Plane insists on a stand name: a start with only an airport is answered
# with "It needs both an 'airport_id' and a 'ramp' key" (measured against
# 12.4.3, 25.08.2026 -- and there is no endpoint that lists stands, so the
# scenery has to be read).
#
# What this does NOT do is judge the stand.  No occupancy, no jetway, no
# runway direction, no wake category -- picking a *good* gate is a different
# job an order of magnitude larger than this whole script.  It picks a valid
# one, deterministically, and says which.  Moving is five seconds in the sim.

def apt_dat_files(root: Path):
    """Every scenery pack's apt.dat, in X-Plane's own priority order.

    scenery_packs.ini is that order, top first; disabled packs carry a
    different keyword and drop out by not matching.  The first pack that knows
    an airport is the one the sim will use, so the search stops there.
    """

    global_airports = (root / "Global Scenery" / "Global Airports"
                       / "Earth nav data" / "apt.dat")
    ini = root / "Custom Scenery" / "scenery_packs.ini"
    try:
        zeilen = ini.read_text(encoding="utf-8", errors="replace").splitlines()
    except OSError as exc:
        # The pack list IS X-Plane's priority order.  Without it we would have
        # to guess which scenery the sim actually uses -- and the far more
        # likely reading is that xplane_root does not point at a real
        # installation at all.  Both deserve a stop, not a fallback.
        raise Refused(f"cannot read {ini}: {exc} -- check xplane_root in "
                      f"config.json; X-Plane writes this file on first start")
    for zeile in zeilen:
        if not zeile.startswith("SCENERY_PACK "):
            continue
        rest = zeile[len("SCENERY_PACK "):].strip().rstrip("/")
        if rest == "*GLOBAL_AIRPORTS*":
            # The Gateway scenery that ships with X-Plane -- it holds every
            # airport in the world, so this entry is what makes the search
            # succeed for airports nobody installed by hand.
            yield global_airports
        else:
            yield root / rest / "Earth nav data" / "apt.dat"


def ramps_in(apt: Path, icao: str) -> list[tuple[str, str]]:
    """``(kind, name)`` of every stand of *icao*, or [] if this file lacks it.

    Streamed, not read whole: the global apt.dat is several hundred megabytes
    and holds every airport on earth.
    """

    treffer: list[tuple[str, str]] = []
    drin = False
    try:
        with apt.open(encoding="utf-8", errors="replace") as f:
            for zeile in f:
                teile = zeile.split()
                if not teile:
                    continue
                if teile[0] in ("1", "16", "17"):          # ein Flughafen beginnt
                    if drin:
                        break                              # unserer ist zu Ende
                    drin = len(teile) > 4 and teile[4].upper() == icao
                elif drin and teile[0] == "1300" and len(teile) > 6:
                    # 1300 lat lon heading kind airplane_types name...
                    treffer.append((teile[4], " ".join(teile[6:])))
    except OSError:
        return []
    return treffer


def pick_ramp(root: Path, icao: str) -> tuple[str, Path]:
    """A valid stand at *icao*, and the scenery it came from."""

    for apt in apt_dat_files(root):
        if not apt.is_file():
            continue
        stands = ramps_in(apt, icao)
        if not stands:
            continue
        # Gates first -- a tie-down is a valid stand but rarely what someone
        # flying an airliner meant.  Beyond that the name decides, so the same
        # airport always yields the same stand.
        gates = sorted(n for kind, n in stands if kind == "gate")
        return (gates or sorted(n for _, n in stands))[0], apt
    raise Refused(
        f"no scenery on this installation has parking stands for {icao} -- "
        f"install an airport for it, or name a stand with --ramp")


# -- doing it -----------------------------------------------------------------

def apply_flight(order: dict[str, str], aircraft: dict[str, str] | None,
                 ramp: str) -> bool:
    """Set the flight.  Returns whether X-Plane confirmed it.

    A POST that gets no answer is NOT a failure.  X-Plane serves this API from
    the main loop it also builds worlds with, so if it was already loading when
    the request arrived, it reads it and falls silent -- the flight is applied,
    the confirmation never comes.  That happened on 25.08.2026: the run
    reported "failed", and the aeroplane was sitting at the right stand in the
    right paint the whole time.

    So a timeout returns False and the caller says "not confirmed" instead of
    lying in either direction.
    """
    # Beide Schluessel, immer: X-Plane lehnt einen Start ohne Standnamen ab.
    payload: dict = {"ramp_start": {"airport_id": order["icao"], "ramp": ramp}}
    if aircraft:
        payload["aircraft"] = aircraft
    try:
        status, body = _api("/v3/flight", data=json.dumps(payload).encode(),
                            timeout=POST_TIMEOUT)
    except SimBusy:
        return False
    if status not in (200, 201, 204):
        detail = body.decode("utf-8", "replace").strip()[:200]
        raise Refused(f"X-Plane refused the flight (HTTP {status}): {detail}",
                      code=3)
    return True


def verify(aircraft: dict[str, str] | None) -> tuple[bool, str]:
    """Read back what X-Plane loaded.  ``(could_read, mismatch)``.

    Best effort, never a verdict on its own.  A successful POST makes X-Plane
    rebuild the world, and while it does that its API stops answering for as
    long as the load takes -- minutes for a big airport.  So "I could not read
    it back" is the normal case right after setting a flight, and it says
    nothing about whether the flight is right.

    Only an answer that actually disagrees is a fault.  Silence is silence,
    and the guard that really protects the user runs *before* the POST: the
    .acf and the livery folder are checked on disk, where no timeout can hide
    them.
    """

    if not aircraft:
        return True, ""
    try:
        geladen = read_dataref(ACF_DREF)
        if geladen and not geladen.endswith(aircraft["path"].split("/")[-1]):
            return True, f"X-Plane loaded {geladen} instead of {aircraft['path']}"
        livery = aircraft.get("livery")
        if not livery:
            return bool(geladen), ""
        geladene_livery = (read_dataref(LIVERY_DREF) or "").rstrip("/")
        if geladene_livery and not geladene_livery.endswith(livery):
            return True, (f"the aeroplane is right but the paint is not: "
                          f"X-Plane shows {geladene_livery.split('/')[-1]!r}, "
                          f"not {livery!r}")
        return bool(geladen and geladene_livery), ""
    except Refused:
        # Die API schweigt, weil sie gerade laedt. Das ist kein Befund.
        return False, ""


def probe() -> int:
    """Report what this installation answers.  Reads only, changes nothing.

    This is the setup check: it says whether the simulator is reachable at all
    and whether its clock is running.  It deliberately does not touch the
    flight endpoint -- posting a flight loads a world, which is no business of
    a command whose whole point is to be safe to run.
    """

    print(f"UDP {HOST}:{UDP_PORT} ...")
    ok, why = udp_clock_is_growing()
    print(f"  {'clock is growing -- a world is loaded and running' if ok else why}")
    for path in ("/capabilities", f"/v3/datarefs?filter[name]={CLOCK_DREF}"):
        try:
            status, _ = _api(path)
            note = ""
            if path.startswith("/v3/datarefs") and status == 404:
                note = "  (start screen: no world loaded yet -- ready for a flight)"
            print(f"HTTP {path} -> {status}{note}")
        except Refused as exc:
            print(f"HTTP {path} -> {exc}")
            return 2
    if not ok:
        print("\nNot flying yet. That is fine for setting a flight, "
              "and it is why --probe stops here.")
    return 0


def probe_flight(icao: str) -> int:
    """Ask the simulator whether it accepts a start WITHOUT a stand name.

    Separate from --probe and never implied by it: this one really posts, and
    a successful post loads a world at *icao*.  The answer decides whether
    this script has to read apt.dat at all -- so it is worth asking once, on
    purpose, rather than guessing forever.
    """

    print(f"posting a stand-less start at {icao} -- this LOADS a world.")
    try:
        status, body = _api("/v3/flight", data=json.dumps(
            {"ramp_start": {"airport_id": icao}}).encode())
    except Refused as exc:
        print(f"  {exc}")
        return exc.code
    detail = body.decode("utf-8", "replace").strip()[:400]
    print(f"  HTTP {status} {detail}")
    if status in (200, 201, 204):
        print("  -> a stand name is OPTIONAL; this script needs no apt.dat")
        return 0
    print("  -> a stand name is REQUIRED; the stand has to come from apt.dat")
    return 3


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Apply an ATCMatch flight order to a running X-Plane 12.")
    parser.add_argument("--job", nargs="?", const="", metavar="FILE",
                        help="apply the order from FILE "
                             "(default: the watchlist atcwatch writes)")
    parser.add_argument("--ramp", metavar="NAME",
                        help="park at this stand instead of letting X-Plane choose")
    parser.add_argument("--config", metavar="FILE",
                        help="config.json (default: next to this script; "
                             "aircraft-map.json is looked up beside it)")
    parser.add_argument("--wait", type=float, default=None, metavar="SEC",
                        help="keep asking for up to SEC seconds while X-Plane "
                             "is still coming up (default: ask once); checked "
                             "between attempts, so a running attempt is "
                             "finished")
    parser.add_argument("--expect-id", metavar="ID",
                        help="apply the order only if its id is ID -- refuse if "
                             "the file has meanwhile been rewritten")
    parser.add_argument("--json", action="store_true", dest="as_json",
                        help="report the outcome as one JSON object instead of "
                             "a sentence; the exit code is unchanged")
    parser.add_argument("--probe", action="store_true",
                        help="report what this X-Plane answers, change nothing")
    parser.add_argument("--probe-flight", metavar="ICAO",
                        help="ask whether a start without a stand name is "
                             "accepted -- this really posts and LOADS a world")
    args = parser.parse_args(argv)

    # Was bereits erreicht ist, muss die Verweigerung ueberleben: schlaegt erst
    # das Nachlesen fehl, liegt der Flug trotzdem im Sim, und ein Aufrufer, der
    # daraufhin erneut postet, ueberschreibt einen richtigen Zustand.
    zustand = {"posted": False, "confirmed": False}
    if args.as_json:
        # Ein delegierendes Programm liest genau einen Kanal. Waeren die
        # Fehlerausgaenge Prosa, muesste es fuer die haeufigsten Faelle --
        # Auftrag gewechselt, Sim nicht bereit, Config falsch -- doch wieder
        # Text lesen. Die Exit-Codes bleiben unveraendert.
        try:
            return _anwenden(args, parser, zustand)
        except Refused as exc:
            print(json.dumps({**zustand, "error": str(exc), "exit": exc.code},
                             ensure_ascii=False))
            return exc.code
    return _anwenden(args, parser, zustand)


def _anwenden(args, parser, zustand: dict[str, bool]) -> int:
    if args.probe or args.probe_flight:
        # --probe-flight postet und laedt eine Welt. Zusammen mit --expect-id
        # sieht das aus wie eine abgesicherte Anwendung, ist aber keine: die
        # Probe kennt keinen Auftrag und pruefte die Erwartung nie.
        stoerend = [n for n, v in (("--expect-id", args.expect_id),
                                   ("--wait", args.wait),
                                   ("--json", args.as_json or None))
                    if v is not None and v is not False]
        if stoerend:
            parser.error(f"{', '.join(stoerend)} does not apply to --probe / "
                         f"--probe-flight -- those answer about the sim, they "
                         f"do not apply an order")
        return probe() if args.probe else probe_flight(args.probe_flight.upper())
    if args.job is None:
        parser.error("nothing to do -- pass --job (or --probe)")
    if args.wait is not None and (args.wait < 0 or not math.isfinite(args.wait)):
        parser.error("--wait needs a finite number of seconds")
    wartefrist = args.wait or 0.0

    watchlist = Path(args.job).expanduser() if args.job else default_watchlist()
    # Neben dem Skript, NICHT neben der Watchlist: dort liegt atcwatch' eigene
    # config.json, und die lehnt unbekannte Felder ab -- ein xplane_root darin
    # legte den Notifier lahm ("unknown config fields"). Zwei Werkzeuge, zwei
    # Dateien.
    hier = Path(__file__).resolve().parent
    cfg_path = Path(args.config).expanduser() if args.config else hier / "config.json"
    amap_path = cfg_path.with_name("aircraft-map.json")

    order = read_order(watchlist)
    # The stand was chosen for one particular order.  ATCMatch may write a new
    # one into the same file while the sim spends minutes building a world, and
    # then a different flight would be loaded -- at a stand that does not
    # belong to it.
    if args.expect_id is not None and not args.expect_id.strip():
        raise Refused("--expect-id is empty -- name the order id or drop the "
                      "switch; an empty value would apply whatever the file "
                      "happens to hold")
    if args.expect_id is not None and order["id"] != args.expect_id:
        raise Refused(f"the order changed: {args.expect_id} was confirmed, the "
                      f"file now holds {order['id']} ({order['icao']}) -- "
                      f"nothing applied")
    cfg = load_config(cfg_path)
    root = cfg.get("xplane_root")
    if root is not None and not isinstance(root, str):
        raise Refused(f"{cfg_path}: xplane_root must be a string")
    db = cfg.get("liveries_db")
    if db is not None and not isinstance(db, str):
        raise Refused(f"{cfg_path}: liveries_db must be a string")
    if db is not None and not db.strip():
        raise Refused(f"{cfg_path}: liveries_db is empty -- name a database or "
                      f"drop the field; an empty value would quietly use "
                      f"aircraft-map.json instead")
    db_path = Path(db.strip()).expanduser() if db is not None else None
    if db_path is not None and not db_path.is_file():
        raise Refused(f"{cfg_path}: liveries_db {db_path} does not exist -- "
                      f"fix the path or drop the field; falling back to "
                      f"aircraft-map.json would answer from an older copy of "
                      f"the same hangar")
    # With a database configured the map is not even read: two hangars that
    # disagree is exactly what the field is there to prevent.
    amap = {} if db_path else {k: v for k, v in load_config(amap_path).items()
                               if not k.startswith("_")}

    if wartefrist:
        ready, starting, why = wait_for_sim(wartefrist)
        if not (ready or starting):
            raise Refused(f"{why} -- still not ready after "
                          f"{wartefrist:.0f} s", code=2)
    else:
        ready, why = udp_clock_is_growing()
        starting = False
        if not ready:
            # The start screen is the one state where silence is not a fault.
            # Ask over HTTP -- and if HTTP is silent too, the UDP answer is the
            # more accurate one: the sim is simply not there.
            try:
                starting = in_start_screen()
            except SimBusy as busy:
                # UDP schweigt und HTTP haengt: das ist kein toter Sim, das ist
                # einer, der gerade eine Welt baut. Jetzt einen Flug zu setzen
                # hiesse, in einen laufenden Ladevorgang hineinzureden.
                raise SimBusy(f"{busy}. Wait for the world to finish loading, "
                              f"then press it again")
            except Refused:
                starting = False
            if not starting:
                raise Refused(why, code=2)

    # Der Stand ist Pflicht (X-Plane 12.4.3: "It needs both an 'airport_id'
    # and a 'ramp' key"), also braucht es die Szenerie -- ausser jemand nennt
    # den Stand selbst.
    if args.ramp:
        ramp, woher = args.ramp, None
    elif not root:
        raise Refused(
            f"xplane_root is not set in {cfg_path} -- X-Plane needs a parking "
            f"stand by name, and the stand comes from your scenery. Set it, "
            f"or pass --ramp NAME")
    else:
        ramp, woher = pick_ramp(Path(root).expanduser(), order["icao"])

    aircraft, note = resolve_aircraft(order, amap, root, db_path)

    if args.expect_id is not None:
        # Die letzte Gelegenheit, und deshalb genau hier: alles davor -- das
        # Warten auf den Sim, die Standsuche (bis 2 s in der Global-Airports-
        # Datei), die Musterauflösung -- kostet Zeit, in der ATCMatch einen
        # neuen Auftrag in dieselbe Datei schreiben kann. Ab der naechsten
        # Zeile aendert sich der Simulator.
        jetzt = read_order(watchlist)
        if jetzt["id"] != args.expect_id:
            raise Refused(f"the order changed while the sim came up: "
                          f"{args.expect_id} was confirmed, the file now holds "
                          f"{jetzt['id']} ({jetzt['icao']}) -- nothing applied")

    # A cold aeroplane, always: the order describes a departure from a stand,
    # and X-Plane otherwise hands over one with the engines already turning.
    set_dref_udp("sim/operation/prefs/startup_running", 0.0)
    bestaetigt = apply_flight(order, aircraft, ramp)
    # Ab hier ist etwas passiert. Eine Ablehnung wirft vorher und laesst
    # posted=False stehen: dann hat der Sim nichts getan.
    zustand["posted"] = True
    zustand["confirmed"] = bestaetigt

    # Ab hier ist der Flug gesetzt: X-Plane hat den POST angenommen, und das
    # ist seine eigene Auskunft. Was danach passiert, kann das nicht mehr
    # entwerten -- ein Nachmessen, das ins Leere laeuft, darf kein Fehlschlag
    # sein. Genau das ist am 25.08.2026 passiert: der Flug sass, das Lesen
    # lief in den Timeout, und die Meldung sagte "failed".
    where = f"{order['icao']} {ramp} -> {order['dest_icao']}"

    # Was dieses Werkzeug ueber den Ausgang weiss, in Feldern statt in Prosa.
    # Der Exit-Code bleibt, wie er ist -- atcwatch kennt nur "0 oder kaputt"
    # (tools/atcwatch/core.py), und ein eigener Code fuer "unbestaetigt" haette
    # dort wieder rote Fehlschlaege fuer sitzende Fluege gemeldet. Wer mehr
    # wissen muss als "hat geklappt", fragt danach.
    def bericht(confirmed: bool, gelesen: bool, saetze: list[str]) -> int:
        if args.as_json:
            # "requested", nicht "set": ohne Bestaetigung ist unbekannt, ob
            # X-Plane den POST ueberhaupt angenommen hat, und read_back ist nur
            # dann wahr, wenn wirklich gelesen wurde -- ohne Muster liest
            # verify() gar nichts und meldet trotzdem Erfolg.
            print(json.dumps({
                "posted": True,
                "confirmed": confirmed,
                "read_back": bool(confirmed and aircraft is not None and gelesen),
                "aircraft_requested": aircraft["path"] if aircraft else None,
                "livery_requested": (aircraft or {}).get("livery") or None,
                "icao": order["icao"],
                "ramp": ramp,
                "dest_icao": order["dest_icao"],
                "order_id": order["id"],
                "note": "; ".join(saetze),
            }, ensure_ascii=False))
        else:
            print(f"{where} {'set' if confirmed else 'sent'}"
                  + ("" if not saetze else " -- " + "; ".join(saetze)))
        return 0

    if not bestaetigt:
        # Angenommen hat er ihn mit hoher Wahrscheinlichkeit -- schweigen tut
        # er, weil er baut. Das zu behaupten waere trotzdem zu viel, also
        # steht es genau so da.
        return bericht(False, False, [
            "X-Plane was busy loading and did not confirm; look at the sim in "
            "a minute", *([note] if note else [])])

    gelesen, fehler = verify(aircraft)
    if fehler:
        raise Refused(f"{where} set, but {fehler}", code=3)
    anmerkungen = [n for n in (note,) if n]
    if aircraft and not gelesen:
        anmerkungen.append("X-Plane is loading, so not read back")
    return bericht(True, gelesen, anmerkungen)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Refused as exc:
        print(exc, file=sys.stderr)
        sys.exit(exc.code)
    except KeyboardInterrupt:
        sys.exit(1)
