//! The controller supervisor: the app's single owner of the Click pods.
//!
//! The same shape as [`crate::trainer`], and for the same reason — the Tauri
//! commands hold a `Mutex` and may never `.await` a radio, so all BLE work
//! happens in one background task that they reach over a channel.
//!
//! ```text
//!   commands ──connect/disconnect──► [supervisor task] ──► ClickClient (+ pod)
//!                                              └────────► ClickClient (− pod)
//!   webview  ◄──controller://input──── button edges ◄──────────────┘
//! ```
//!
//! **A Click v2 is two peripherals, not one** (§2.3.1, FR-1.4). They advertise
//! the same local name, so the old single-slot supervisor asked for "a Zwift
//! Click" and got whichever pod answered first — a coin toss, with the loser
//! invisible and no way to tell which one you had. Each pod now has its own
//! slot, its own link and its own line on screen, keyed by the shift paddle it
//! carries rather than by which end of the bar it is clamped to (see
//! [`PodId`]).
//!
//! Connect attempts run in child tasks rather than inline, so a twenty-second
//! search for a sleeping pod cannot hold up the other pod's buttons (A-4).
//! Each attempt carries a generation, and a result from a superseded attempt is
//! discarded — with its link closed, never merely dropped (SAF-9).
//!
//! The difference from the trainer is that a controller has **no safety
//! story**: it never commands load, so there is no SAF-2 sequence to run and
//! nothing to reset on exit. It still has to be *disconnected* on the way out
//! though (SAF-9) — a link the process merely abandons can leave the pod held
//! by BlueZ and unreachable on the next launch — and that disconnect has to be
//! waited for, which is what [`ControllerHandle::shutdown_blocking`] is.
//!
//! Button *edges* arrive already de-duplicated by `ButtonTracker` in the BLE
//! layer — the pod repeats a held button at ~10 Hz, and acting on repeats would
//! shift ten gears per second.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender};
use std::sync::Arc;
use std::time::{Duration, Instant};

use bikecontrol_ble::click::{ClickClient, ClickConfig, ClickEvent};
use bikecontrol_ble::zwift::{Button, PodId};
use bikecontrol_ble::{Backoff, FtmsError, PodSelector};
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, oneshot, watch};

/// How long a connected pod may stay silent before the screen says so.
///
/// This used to be 20 s, on the stated grounds that "the pod sends a battery
/// heartbeat every ~5 s even when idle". **Nothing confirms that** — §2.3.1
/// establishes the battery frame's *format*, never its cadence, and a two-minute
/// capture with both pods connected recorded six frames from one pod and none at
/// all from the other. A Click's whole character is to be quiet.
///
/// So the old window was shorter than an ordinary gap between shifts, and a pod
/// that was working perfectly was declared stale for the crime of not being
/// pressed. Three minutes is longer than any silence a working link plausibly
/// explains, and the silence is logged when it fires so the next ride's log can
/// replace this guess with the pods' real cadence.
const STALE_AFTER: Duration = Duration::from_secs(180);
/// How long a *live* link may go without ever carrying a button before we stop
/// believing in it.
///
/// This is not "the rider has not shifted lately" — that is normal, and tearing
/// down a working link over it would strand a pod that only advertises while
/// awake. It is the narrower and much stranger case from §7.1: frames arriving
/// steadily, battery every five seconds, and not one press since the link came
/// up. A healthy pod proves itself with its first button and is then never
/// touched by this; a wedged one never does, and recycling costs a few seconds
/// against a pod that is otherwise useless for the whole ride.
const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
/// Upper bound on closing the controller links at exit. Shorter than the
/// trainer's: there is no reset sequence here, only an unsubscribe and a
/// disconnect, and this budget is spent on the same window close (NFR-9).
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// How long auto-reconnect keeps chasing a pod before it gives up and says so.
///
/// FR-1.11. More generous than the trainer's, because a Click genuinely does
/// sleep between efforts and only advertises after a button press (A-4), so a
/// quiet pod is normal where a quiet trainer is not. It is still bounded:
/// a flat pod will not come back, and scanning for it every thirty seconds
/// forever competes with the trainer — and with the other pod — for the one
/// adapter.
const RECONNECT_ATTEMPTS: u32 = 30;

const _: () = assert!(
    RECONNECT_ATTEMPTS > crate::trainer::RECONNECT_ATTEMPTS,
    "a pod that is merely asleep must not be given up on sooner than a trainer"
);

/// The controller configuration this app rides with.
pub fn controller_config() -> ClickConfig {
    ClickConfig {
        backoff: Backoff {
            max_attempts: Some(RECONNECT_ATTEMPTS),
            ..Backoff::default()
        },
        ..ClickConfig::default()
    }
}

/// Which pod, as the webview names it. Mirrors [`PodId`], which lives in the
/// BLE crate and carries no serde derives for the app's benefit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Pod {
    Minus,
    Plus,
}

impl From<PodId> for Pod {
    fn from(id: PodId) -> Self {
        match id {
            PodId::Minus => Pod::Minus,
            PodId::Plus => Pod::Plus,
        }
    }
}

impl From<Pod> for PodId {
    fn from(pod: Pod) -> Self {
        match pod {
            Pod::Minus => PodId::Minus,
            Pod::Plus => PodId::Plus,
        }
    }
}

/// Where one pod's link has got to.
///
/// Deliberately more than a bool. "Not connected" covers a pod that is asleep,
/// one that is being searched for right now, and one the app has stopped
/// chasing — three situations with three different things for the rider to do,
/// and a single flag could not tell them apart (FR-9.2).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PodState {
    /// Never asked for, or disconnected on purpose.
    #[default]
    Idle,
    /// A connect is in flight. A pod only advertises after a button press, so
    /// this can legitimately sit here for the whole scan timeout.
    Searching,
    /// Connected and talking.
    Connected,
    /// The link dropped and the BLE layer is chasing it. The ride carries on.
    Reconnecting,
    /// The app has stopped chasing (FR-1.11). Terminal until the rider acts.
    GaveUp,
}

/// What the UI needs to know about one pod.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PodStatus {
    pub pod: Pod,
    /// `+` or `−`, so the webview never has to map an enum to a glyph.
    pub symbol: &'static str,
    pub state: PodState,
    pub address: Option<String>,
    pub name: Option<String>,
    pub battery_percent: Option<u8>,
    /// Rendered verbatim (FR-9.2).
    pub error: Option<String>,
    /// The last button this pod sent, as [`button_name`] spells it. The point
    /// is diagnostic: a pod that is connected but has never sent anything looks
    /// exactly like a working one until you press something.
    pub last_button: Option<&'static str>,
    pub buttons_seen: u32,
    /// This pod has sent its own paddle, so the label above is no longer a
    /// reading of the type byte — it is what the rider pressed.
    pub confirmed: bool,
    /// This pod has sent the *other* pod's paddle. Either the pair is filed the
    /// wrong way round (see [`ControllerHandle::swap`]), or this pod reports
    /// for both.
    pub contradicted: bool,
}

impl PodStatus {
    fn new(pod: PodId) -> Self {
        Self {
            pod: pod.into(),
            symbol: pod.symbol(),
            state: PodState::Idle,
            address: None,
            name: None,
            battery_percent: None,
            error: None,
            last_button: None,
            buttons_seen: 0,
            confirmed: false,
            contradicted: false,
        }
    }

    pub fn connected(&self) -> bool {
        self.state == PodState::Connected
    }

    /// Forget everything about the link, keeping what we know about the pod
    /// itself. The address survives on purpose: it is how the next connect
    /// finds this pod rather than its identically-named twin.
    fn reset_link(&mut self, state: PodState) {
        self.state = state;
        self.battery_percent = None;
        self.error = None;
    }
}

/// What the UI needs to know about the controller as a whole.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllerStatus {
    pub minus: PodStatus,
    pub plus: PodStatus,
    /// The type-byte reading has been flipped by the rider. §2.3.1 leaves which
    /// byte is which pod unproven, so the default is a documented guess with an
    /// escape hatch rather than a fact.
    pub swapped: bool,
}

impl Default for ControllerStatus {
    fn default() -> Self {
        Self {
            minus: PodStatus::new(PodId::Minus),
            plus: PodStatus::new(PodId::Plus),
            swapped: false,
        }
    }
}

impl ControllerStatus {
    pub fn get(&self, pod: PodId) -> &PodStatus {
        match pod {
            PodId::Minus => &self.minus,
            PodId::Plus => &self.plus,
        }
    }

    fn get_mut(&mut self, pod: PodId) -> &mut PodStatus {
        match pod {
            PodId::Minus => &mut self.minus,
            PodId::Plus => &mut self.plus,
        }
    }

    /// Any pod talking at all. Shifting keeps working with one pod, which is
    /// why this is not `both`.
    pub fn any_connected(&self) -> bool {
        self.minus.connected() || self.plus.connected()
    }

    pub fn both_connected(&self) -> bool {
        self.minus.connected() && self.plus.connected()
    }
}

/// One physical press, however many pods report it.
///
/// The pair does **not** divide the buttons between them. §2.3.1 records every
/// one of the ten arriving over the `0x0B` pod alone and leaves open what the
/// other contributes; the answer is that the `+` paddle arrives over *both* —
/// once from the pod it is printed on and once relayed by its twin. Forwarding
/// each pod's edges as they came therefore shifted twice for one press of `+`,
/// while `−`, which only its own pod reports, shifted once. That is the `+2`
/// upshift against a `−1` downshift, and it is visible in a ride log as a gear
/// going 7 → 9 → 11 on three presses.
///
/// So the two pods are merged into one controller: a button is held when
/// *either* pod says it is, and only the transitions of that aggregate reach
/// the app. Releases carry as much weight as presses here — the aggregate
/// falling back to "nobody holds it" is what re-arms the next press, which is
/// why every path that can lose a release ([`forget`]) has to say so.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct Buttons {
    /// One mask per pod, bit per [`Button::bit`]. The highest is 12, so a `u16`
    /// holds the lot.
    minus: u16,
    plus: u16,
}

impl Buttons {
    fn mask_mut(&mut self, pod: PodId) -> &mut u16 {
        match pod {
            PodId::Minus => &mut self.minus,
            PodId::Plus => &mut self.plus,
        }
    }

    /// Does any pod believe this button is held?
    fn held(&self, button: Button) -> bool {
        (self.minus | self.plus) & (1u16 << button.bit()) != 0
    }

    /// Fold in one pod's edge. `Some(pressed)` when the controller as a whole
    /// changed state — that is, when the edge is worth forwarding. `None` when
    /// the other pod had already said the same thing.
    fn edge(&mut self, pod: PodId, button: Button, pressed: bool) -> Option<bool> {
        let before = self.held(button);
        let bit = 1u16 << button.bit();
        let mask = self.mask_mut(pod);
        if pressed {
            *mask |= bit;
        } else {
            *mask &= !bit;
        }
        let after = self.held(button);
        (after != before).then_some(after)
    }

    /// Drop everything this pod claimed to be holding, and report which buttons
    /// nobody is holding any more.
    ///
    /// Called wherever a release edge could have been missed — a dropped link, a
    /// lagged channel. A release that never arrives would leave the aggregate
    /// latched, and a latched paddle does not shift at all, which is a worse
    /// failure than the one this type exists to fix.
    fn forget(&mut self, pod: PodId) -> Vec<Button> {
        let dropped = std::mem::take(self.mask_mut(pod));
        Button::ALL
            .into_iter()
            .filter(|b| dropped & (1u16 << b.bit()) != 0 && !self.held(*b))
            .collect()
    }
}

/// Forget one pod's held buttons, sending the releases that implies.
///
/// The webview acts only on presses, but a release it never sees is a release
/// it cannot un-highlight, and the symmetry is cheap.
fn forget(
    pod: PodId,
    buttons: &mut Buttons,
    input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
) {
    for button in buttons.forget(pod) {
        let _ = input_tx.send(ControllerInput {
            button: button_name(button),
            pressed: false,
            pod: pod.into(),
        });
    }
}

/// A button edge, on its way to the webview.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllerInput {
    /// Stable lowercase name: `left`, `up`, `right`, `down`, `a`, `b`, `y`,
    /// `z`, `minus`, `plus`. The webview switches on this, so it must not drift
    /// from [`button_name`].
    pub button: &'static str,
    pub pressed: bool,
    /// Which pod it came from. The webview does not route on this — a `+` press
    /// means the same thing whichever pod sent it — but it lets the connection
    /// screen show that a particular pod is alive.
    pub pod: Pod,
}

/// The webview-facing name for a button. Deliberately not `Button::label`,
/// which returns `-` and `+` — awkward to switch on in TypeScript.
pub fn button_name(button: Button) -> &'static str {
    match button {
        Button::Left => "left",
        Button::Up => "up",
        Button::Right => "right",
        Button::Down => "down",
        Button::A => "a",
        Button::B => "b",
        Button::Y => "y",
        Button::Z => "z",
        Button::Minus => "minus",
        Button::Plus => "plus",
    }
}

enum Cmd {
    /// Connect one pod. `address` is used when the scanner has already seen it,
    /// which is both faster and unambiguous.
    Connect { pod: PodId, address: Option<String> },
    /// The scanner has this pod in view *right now* (FR-1.5).
    ///
    /// The whole difficulty with a Click is that it advertises for only a few
    /// seconds after a button press, so "press Connect and hope" is a race the
    /// rider loses about half the time. The device scan is already running and
    /// already knows the moment a pod appears — this is that moment, and the
    /// supervisor connects on it.
    Seen { pod: PodId, address: String },
    /// Disconnect one pod, or both when `None`.
    Disconnect { pod: Option<PodId> },
    /// The pair is filed the wrong way round: exchange the two slots.
    Swap,
    /// Close every link and stop, answering only once they are actually closed.
    Shutdown { reply: SyncSender<()> },
}

/// The outcome of one connect attempt, back from its child task.
struct Attempt {
    pod: PodId,
    /// Which attempt this was, so a result superseded by a newer request (or by
    /// a disconnect) can be told apart from a live one.
    generation: u64,
    outcome: Result<Option<ClickClient>, FtmsError>,
}

/// Cheap, cloneable handle to the supervisor.
#[derive(Clone)]
pub struct ControllerHandle {
    cmd_tx: mpsc::Sender<Cmd>,
    status_rx: watch::Receiver<ControllerStatus>,
    /// Button edges, for whoever forwards them to the webview.
    input_tx: Arc<tokio::sync::broadcast::Sender<ControllerInput>>,
    /// Shared, so every clone of the handle sees that the links are already
    /// shut.
    shut_down: Arc<AtomicBool>,
}

impl ControllerHandle {
    /// Start the supervisor task. Needs a Tokio runtime, which
    /// `tauri::async_runtime` provides before the app is built.
    pub fn spawn() -> Self {
        let (cmd_tx, cmd_rx) = mpsc::channel(16);
        let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, _) = tokio::sync::broadcast::channel(64);
        let input_tx = Arc::new(input_tx);

        let handle = Self {
            cmd_tx,
            status_rx,
            input_tx: input_tx.clone(),
            shut_down: Arc::new(AtomicBool::new(false)),
        };
        tauri::async_runtime::spawn(run(cmd_rx, status_tx, input_tx));
        handle
    }

    pub fn status(&self) -> ControllerStatus {
        self.status_rx.borrow().clone()
    }

    /// A watch receiver, for a loop that wants to react to link changes rather
    /// than poll them.
    pub fn status_watch(&self) -> watch::Receiver<ControllerStatus> {
        self.status_rx.clone()
    }

    /// Subscribe to button edges.
    pub fn inputs(&self) -> tokio::sync::broadcast::Receiver<ControllerInput> {
        self.input_tx.subscribe()
    }

    /// Connect one pod. `address` short-circuits the search when the device
    /// scanner has already seen it.
    pub fn connect(&self, pod: PodId, address: Option<String>) {
        let _ = self.cmd_tx.try_send(Cmd::Connect { pod, address });
    }

    /// Tell the supervisor a pod is advertising right now (FR-1.5).
    ///
    /// Called from the device scan on every pass. Cheap and idempotent: the
    /// supervisor ignores it unless that pod is disconnected and the rider has
    /// not disconnected it on purpose. Dropped rather than queued if the
    /// supervisor is busy — the scan will say so again in half a second.
    pub fn pod_seen(&self, pod: PodId, address: &str) {
        let _ = self.cmd_tx.try_send(Cmd::Seen {
            pod,
            address: address.to_string(),
        });
    }

    pub fn disconnect(&self, pod: Option<PodId>) {
        let _ = self.cmd_tx.try_send(Cmd::Disconnect { pod });
    }

    /// Exchange the two slots, for when the pods answer to the other name.
    pub fn swap(&self) {
        let _ = self.cmd_tx.try_send(Cmd::Swap);
    }

    /// Close both controller links at app exit, waiting for them to be closed
    /// (SAF-9). Blocks the calling (non-async) thread until it is done or
    /// [`SHUTDOWN_TIMEOUT`] elapses.
    ///
    /// Idempotent: Tauri delivers `ExitRequested`, `Exit` and window `Destroyed`
    /// for a single quit, and the repeats must be silent no-ops.
    pub fn shutdown_blocking(&self) {
        if self.shut_down.swap(true, Ordering::SeqCst) {
            return;
        }
        let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
        let (reply, done) = sync_channel(1);

        let mut cmd = Cmd::Shutdown { reply };
        loop {
            match self.cmd_tx.try_send(cmd) {
                Ok(()) => break,
                Err(mpsc::error::TrySendError::Closed(_)) => return,
                Err(mpsc::error::TrySendError::Full(returned)) => {
                    if Instant::now() >= deadline {
                        tracing::warn!("controller supervisor unreachable; links left to the OS");
                        return;
                    }
                    cmd = returned;
                    std::thread::sleep(Duration::from_millis(20));
                }
            }
        }

        let remaining = deadline.saturating_duration_since(Instant::now());
        match done.recv_timeout(remaining) {
            Ok(()) => tracing::info!("controller pods disconnected"),
            Err(e) => tracing::warn!(error = %e, "controller did not disconnect in time"),
        }
    }
}

/// One slot's live state. There are exactly two, and they never share a
/// peripheral: each holds its own client and its own event stream.
struct Slot {
    client: Option<ClickClient>,
    events: Option<tokio::sync::broadcast::Receiver<ClickEvent>>,
    /// Held while a connect is in flight; sending on it — or dropping it —
    /// abandons the attempt (FR-1.10).
    cancel: Option<oneshot::Sender<()>>,
    generation: u64,
    last_seen: Option<tokio::time::Instant>,
    /// When the current link was established, and whether it has ever carried a
    /// button. Together these are the signature of the wedged session in §7.1:
    /// frames arriving, and not one press among them.
    connected_at: Option<tokio::time::Instant>,
    buttons_this_link: u32,
    /// May this pod be connected the moment the scan sees it?
    ///
    /// True until the rider disconnects it by hand, because a pod that
    /// advertises for three seconds after a button press is not something a
    /// human can reliably click Connect at. False afterwards: "disconnect"
    /// that reconnects itself two seconds later is not a disconnect.
    auto: bool,
}

impl Default for Slot {
    fn default() -> Self {
        Self {
            client: None,
            events: None,
            connected_at: None,
            buttons_this_link: 0,
            cancel: None,
            generation: 0,
            last_seen: None,
            auto: true,
        }
    }
}

impl Slot {
    fn connecting(&self) -> bool {
        self.cancel.is_some()
    }

    /// Is this slot free to start a connect?
    fn idle(&self) -> bool {
        self.client.is_none() && !self.connecting()
    }

    /// Abandon any in-flight attempt and bump the generation, so its result is
    /// recognised as stale when it arrives.
    fn abandon_attempt(&mut self) {
        if let Some(cancel) = self.cancel.take() {
            let _ = cancel.send(());
        }
        self.generation += 1;
    }
}

async fn run(
    mut cmd_rx: mpsc::Receiver<Cmd>,
    status_tx: watch::Sender<ControllerStatus>,
    input_tx: Arc<tokio::sync::broadcast::Sender<ControllerInput>>,
) {
    // Two slots, held as separate locals rather than in a collection: the
    // `select!` below polls both event streams at once, which needs two
    // disjoint borrows.
    let mut minus = Slot::default();
    let mut plus = Slot::default();
    let mut swapped = false;
    // One press is one press, whichever pods report it (see `Buttons`).
    let mut buttons = Buttons::default();

    let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
    let mut housekeeping = tokio::time::interval(Duration::from_secs(5));

    loop {
        tokio::select! {
            cmd = cmd_rx.recv() => {
                let Some(cmd) = cmd else {
                    // Every handle dropped: nobody can ask for anything again.
                    shutdown_all(&mut minus, &mut plus).await;
                    return;
                };
                match cmd {
                    Cmd::Connect { pod, address } => {
                        let slot = slot_mut(&mut minus, &mut plus, pod);
                        // Asking for it by hand re-arms auto-connect, whatever
                        // came before.
                        slot.auto = true;
                        // A second click while a search is running means
                        // "connect", which is what is already happening.
                        if slot.connecting() {
                            tracing::debug!(pod = pod.as_str(), "controller: already connecting");
                            continue;
                        }
                        if slot.client.is_some() {
                            tracing::debug!(pod = pod.as_str(), "controller: already connected");
                            continue;
                        }
                        let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
                        tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
                        start_attempt(slot, pod, selector, &attempt_tx);
                        status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
                    }
                    Cmd::Seen { pod, address } => {
                        // One link is the whole controller.
                        //
                        // The `−` pod relays its twin: connected on its own it
                        // delivers its own paddle and D-pad *and* the `+`
                        // paddle and face buttons — all ten buttons, measured
                        // over 445 frames on one characteristic (§2.3.1). So
                        // the `+` pod is not connected while the `−` pod is
                        // there to speak for it. It stays a fallback for the
                        // case where the `−` pod is absent or the rider only
                        // owns that half.
                        //
                        // Connecting both is what the pair-merge in `Buttons`
                        // exists to paper over, and it is also the
                        // configuration in which the `−` pod stops reporting
                        // its own paddle — the failure that cost an evening.
                        // Not opening the second link removes both.
                        if pod == PodId::Plus && !slot_mut(&mut minus, &mut plus, PodId::Minus).idle()
                        {
                            tracing::debug!(
                                "controller: + pod seen but the − pod is already speaking for it"
                            );
                            continue;
                        }
                        let slot = slot_mut(&mut minus, &mut plus, pod);
                        if !slot.auto || !slot.idle() {
                            continue;
                        }
                        // Straight to the address: the pod is advertising *now*,
                        // and the seconds spent rediscovering what the scan has
                        // already found are the seconds it goes back to sleep in.
                        tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
                        start_attempt(slot, pod, PodSelector::Address(address.clone()), &attempt_tx);
                        status_tx.send_modify(|s| {
                            let p = s.get_mut(pod);
                            p.address = Some(address);
                            p.reset_link(PodState::Searching);
                        });
                    }
                    Cmd::Disconnect { pod } => {
                        let pods: Vec<PodId> = pod.map_or_else(|| PodId::BOTH.to_vec(), |p| vec![p]);
                        for id in pods {
                            let slot = slot_mut(&mut minus, &mut plus, id);
                            slot.abandon_attempt();
                            slot.events = None;
                            slot.last_seen = None;
                            // Stays disconnected. Auto-connect exists so a pod
                            // that wakes for three seconds is caught; it must
                            // not undo a rider who meant it.
                            slot.auto = false;
                            if let Some(c) = slot.client.take() {
                                c.shutdown().await;
                            }
                            forget(id, &mut buttons, &input_tx);
                            status_tx.send_modify(|s| s.get_mut(id).reset_link(PodState::Idle));
                        }
                    }
                    Cmd::Swap => {
                        // Both attempts are abandoned first: an in-flight
                        // search is for a pod id that is about to mean the
                        // other peripheral.
                        minus.abandon_attempt();
                        plus.abandon_attempt();
                        std::mem::swap(&mut minus, &mut plus);
                        // The peripherals move slots with everything else they
                        // are holding, so what is held moves with them. The
                        // aggregate is unchanged, which is the point: a swap is
                        // a relabelling, not a release.
                        std::mem::swap(&mut buttons.minus, &mut buttons.plus);
                        swapped = !swapped;
                        status_tx.send_modify(|s| {
                            swap_status(s);
                            s.swapped = swapped;
                        });
                        tracing::info!(swapped, "controller: pods exchanged");
                    }
                    Cmd::Shutdown { reply } => {
                        shutdown_all(&mut minus, &mut plus).await;
                        status_tx.send_modify(|s| {
                            for id in PodId::BOTH {
                                s.get_mut(id).reset_link(PodState::Idle);
                            }
                        });
                        // Answered only once the links are genuinely closed, so
                        // a caller blocking the app's exit on this knows what it
                        // waited for (SAF-9).
                        let _ = reply.send(());
                        return;
                    }
                }
            }

            Some(attempt) = attempt_rx.recv() => {
                let slot = slot_mut(&mut minus, &mut plus, attempt.pod);
                if attempt.generation != slot.generation {
                    // Superseded — by a disconnect, a swap, or a newer request.
                    // A client that arrived anyway owns a live GATT link, and
                    // dropping it would leave the pod held by BlueZ (SAF-9).
                    if let Ok(Some(client)) = attempt.outcome {
                        tracing::debug!(pod = attempt.pod.as_str(), "controller: stale connect; closing");
                        tokio::spawn(async move { client.shutdown().await });
                    }
                    continue;
                }
                slot.cancel = None;
                apply_attempt(attempt, slot, &status_tx);
            }

            // Only polled while that pod has a stream; `recv` on a `None`
            // receiver would busy-loop, so the branch is disabled instead.
            event = async { minus.events.as_mut().unwrap().recv().await }, if minus.events.is_some() => {
                handle_event(PodId::Minus, event, &mut minus, &mut buttons, &status_tx, &input_tx);
            }
            event = async { plus.events.as_mut().unwrap().recv().await }, if plus.events.is_some() => {
                handle_event(PodId::Plus, event, &mut plus, &mut buttons, &status_tx, &input_tx);
            }

            _ = housekeeping.tick() => {
                // Converge on one link. The `+` pod may have connected first —
                // it is the one the rider happened to wake — and once the `−`
                // pod is up it speaks for both, so the second link is redundant
                // and is exactly the configuration that breaks the `−` paddle.
                // Dropping it leaves `auto` alone, so if the `−` pod later goes
                // away the `+` pod is picked up again on its next advertisement.
                if !minus.idle() && plus.client.is_some() {
                    tracing::info!(
                        "controller: − pod is up and relays the pair; closing the redundant + link"
                    );
                    forget(PodId::Plus, &mut buttons, &input_tx);
                    plus.generation += 1;
                    plus.events = None;
                    plus.last_seen = None;
                    plus.connected_at = None;
                    if let Some(client) = plus.client.take() {
                        tokio::spawn(async move { client.shutdown().await });
                    }
                    status_tx.send_modify(|s| s.get_mut(PodId::Plus).reset_link(PodState::Idle));
                }

                // A link that is plainly alive and has never carried a button
                // is the wedged session from §7.1, and no amount of waiting
                // fixes it — the pod answers the handshake and streams battery
                // for the rest of the ride while ignoring every press. Recycle
                // it. `connect_pod` now drops any pre-existing link first, so
                // the replacement starts genuinely clean.
                //
                // Deliberately narrow: a link that has delivered even one press
                // is exempt for life, so a rider who simply is not shifting is
                // never disturbed.
                for id in PodId::BOTH {
                    let slot = slot_mut(&mut minus, &mut plus, id);
                    let alive = slot.client.is_some()
                        && slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER);
                    let mute = slot.buttons_this_link == 0
                        && slot.connected_at.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER);
                    if alive && mute && slot.auto {
                        tracing::warn!(
                            pod = id.as_str(),
                            "controller: link is alive but has never carried a button; \
                             recycling it (see REQUIREMENTS §7.1)"
                        );
                        slot.generation += 1;
                        slot.connected_at = None;
                        if let Some(client) = slot.client.take() {
                            slot.events = None;
                            tokio::spawn(async move { client.shutdown().await });
                        }
                        status_tx.send_modify(|s| {
                            s.get_mut(id).state = PodState::Reconnecting;
                        });
                    }
                }
                for (id, slot) in [(PodId::Minus, &minus), (PodId::Plus, &plus)] {
                    let silence = slot.last_seen.map(|t| t.elapsed());
                    let silent = slot.client.is_some()
                        && silence.is_some_and(|s| s > STALE_AFTER);
                    if silent {
                        // A guess, and marked as one in the log. The link may be
                        // perfectly alive and the rider simply not shifting; the
                        // truth about a dropped link comes from the BLE layer,
                        // which says `Disconnected` when the stream ends. The
                        // first frame to arrive takes this back.
                        status_tx.send_modify(|s| {
                            let p = s.get_mut(id);
                            if p.state == PodState::Connected {
                                tracing::info!(
                                    pod = id.as_str(),
                                    silent_s = silence.map(|s| s.as_secs()),
                                    "controller: pod has gone quiet; showing it as reconnecting"
                                );
                                p.state = PodState::Reconnecting;
                            }
                        });
                    }
                }
            }
        }
    }
}

fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
    match pod {
        PodId::Minus => minus,
        PodId::Plus => plus,
    }
}

/// How to look for one pod: by address when anything has seen it, otherwise by
/// the type byte its advertisement carries (§2.3.1).
///
/// Never by name — both pods answer to `Zwift Click`, which is precisely how
/// the app used to end up connected to whichever one happened to reply first.
fn selector_for(
    pod: PodId,
    address: Option<String>,
    status: &ControllerStatus,
    swapped: bool,
) -> PodSelector {
    let known = address.or_else(|| status.get(pod).address.clone());
    match known {
        Some(address) if !address.trim().is_empty() => PodSelector::Address(address),
        _ => PodSelector::Pod(if swapped { pod.other() } else { pod }),
    }
}

/// Spawn the connect. It runs in its own task so that a twenty-second search
/// for a sleeping pod does not stall the other pod's buttons, or a quit.
fn start_attempt(
    slot: &mut Slot,
    pod: PodId,
    selector: PodSelector,
    results: &mpsc::Sender<Attempt>,
) {
    slot.generation += 1;
    let generation = slot.generation;
    let (cancel_tx, cancel_rx) = oneshot::channel();
    slot.cancel = Some(cancel_tx);

    let results = results.clone();
    tauri::async_runtime::spawn(async move {
        let outcome = ClickClient::connect_cancellable(selector, controller_config(), async {
            // Resolves on an explicit cancel *and* on the sender being dropped,
            // which is how a supervisor that has gone away stops the search.
            let _ = cancel_rx.await;
        })
        .await;
        let _ = results
            .send(Attempt {
                pod,
                generation,
                outcome,
            })
            .await;
    });
}

/// Fold a finished connect attempt into the slot and its status.
fn apply_attempt(attempt: Attempt, slot: &mut Slot, status_tx: &watch::Sender<ControllerStatus>) {
    let pod = attempt.pod;
    match attempt.outcome {
        Ok(Some(client)) => {
            // `Ok` from the client means the pod answered the handshake, so
            // this is connected — the identity comes off the handle rather than
            // off the event stream, whose first `Connected` was sent before
            // anyone could be subscribed to hear it.
            let address = client.address().to_string();
            let name = client.name().map(str::to_owned);
            let advertised = client.pod();
            slot.events = Some(client.events());
            slot.client = Some(client);
            slot.last_seen = Some(tokio::time::Instant::now());
            // A fresh link starts over: whatever the last one proved says
            // nothing about this one.
            slot.connected_at = Some(tokio::time::Instant::now());
            slot.buttons_this_link = 0;
            status_tx.send_modify(|s| {
                let p = s.get_mut(pod);
                p.state = PodState::Connected;
                p.error = None;
                if !address.is_empty() {
                    p.address = Some(address);
                }
                p.name = name;
            });
            tracing::info!(
                pod = pod.as_str(),
                advertised = advertised.map(PodId::as_str),
                "controller: pod connected"
            );
        }
        Ok(None) => {
            tracing::info!(pod = pod.as_str(), "controller: connect abandoned");
            status_tx.send_modify(|s| s.get_mut(pod).state = PodState::Idle);
        }
        Err(e) => {
            tracing::warn!(pod = pod.as_str(), error = %e, "controller: connect failed");
            status_tx.send_modify(|s| {
                let p = s.get_mut(pod);
                p.state = PodState::Idle;
                p.error = Some(connect_advice(pod, &e));
            });
        }
    }
}

/// Turn a connect failure into something the rider can act on (FR-9.2).
fn connect_advice(pod: PodId, error: &FtmsError) -> String {
    match error {
        // The overwhelmingly common case, and barely an error: the pod is
        // asleep. "Not found" on its own invites the rider to conclude the app
        // is broken (FR-1.8, A-4).
        FtmsError::NotFound(_) => format!(
            "Could not find the {} pod. Press any button on it to wake it — a Click only \
             advertises while awake — then try again.",
            pod.symbol()
        ),
        other => other.to_string(),
    }
}

/// Fold one pod's event into its status and, for buttons, out to the webview.
fn handle_event(
    pod: PodId,
    event: Result<ClickEvent, tokio::sync::broadcast::error::RecvError>,
    slot: &mut Slot,
    buttons: &mut Buttons,
    status_tx: &watch::Sender<ControllerStatus>,
    input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
) {
    use tokio::sync::broadcast::error::RecvError;

    match event {
        // Terminal: the actor has stopped and nothing else will arrive.
        // Everything this slot holds is now a zombie, and unlike a plain
        // `Disconnected` this needs saying out loud — the rider's buttons have
        // stopped working and the app is no longer doing anything about it
        // (FR-1.11).
        Ok(ClickEvent::GaveUp { attempts }) => {
            tracing::warn!(
                pod = pod.as_str(),
                attempts,
                "controller: reconnect gave up"
            );
            slot.events = None;
            slot.client = None;
            slot.last_seen = None;
            forget(pod, buttons, input_tx);
            status_tx.send_modify(|s| {
                let p = s.get_mut(pod);
                p.reset_link(PodState::GaveUp);
                p.error = Some(format!(
                    "Gave up reaching the {} pod after {attempts} attempts. Press a button on \
                     it, then connect again.",
                    pod.symbol()
                ));
            });
        }
        Ok(event) => {
            slot.last_seen = Some(tokio::time::Instant::now());
            if matches!(event, ClickEvent::Button { .. }) {
                // This link has now proven it carries input, which puts it
                // beyond the no-input watchdog for as long as it lasts.
                slot.buttons_this_link = slot.buttons_this_link.saturating_add(1);
            }
            // A frame is proof the link is up, and it is the *only* proof that
            // ever arrives — the pod does not announce that it has started
            // talking again. Without this, the staleness sweep below was a one-
            // way door: a pod flipped to `Reconnecting` for going quiet stayed
            // there for the rest of the ride, shifting perfectly while the
            // screen said it was lost. `Disconnected` and `GaveUp` are excluded
            // because they are the link reporting its own end.
            if !matches!(event, ClickEvent::Disconnected | ClickEvent::GaveUp { .. }) {
                status_tx.send_modify(|s| {
                    let p = s.get_mut(pod);
                    if p.state == PodState::Reconnecting {
                        tracing::info!(pod = pod.as_str(), "controller: pod talking again");
                        p.state = PodState::Connected;
                        p.error = None;
                    }
                });
            }
            apply(pod, event, buttons, status_tx, input_tx);
        }
        // Lagged means we fell behind the pod, not that it left. One of the
        // events we skipped may have been a release, though, and a release lost
        // out of the merge latches that button held forever (see `Buttons`) —
        // so assume the worst and let go of everything this pod was holding.
        Err(RecvError::Lagged(n)) => {
            tracing::warn!(pod = pod.as_str(), "controller: dropped {n} event(s)");
            forget(pod, buttons, input_tx);
        }
        Err(RecvError::Closed) => {
            slot.events = None;
            slot.client = None;
            slot.last_seen = None;
            forget(pod, buttons, input_tx);
            status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Idle));
        }
    }
}

fn apply(
    pod: PodId,
    event: ClickEvent,
    buttons: &mut Buttons,
    status_tx: &watch::Sender<ControllerStatus>,
    input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
) {
    match event {
        ClickEvent::Connected { address, name, .. } => status_tx.send_modify(|s| {
            let p = s.get_mut(pod);
            p.state = PodState::Connected;
            if !address.is_empty() {
                p.address = Some(address);
            }
            p.name = name;
            p.error = None;
        }),
        ClickEvent::Disconnected => {
            // Loud, because this is the one report of a genuinely dropped link —
            // everything else on this screen is inference. A ride that says it
            // keeps losing a pod is either this line repeating or it is not,
            // and those two want opposite fixes.
            tracing::info!(pod = pod.as_str(), "controller: link dropped; the BLE layer is retrying");
            // The BLE layer releases what it was holding before it says this,
            // so the merge is usually already clear; doing it again costs
            // nothing and covers the edge it cannot (see `Buttons::forget`).
            forget(pod, buttons, input_tx);
            status_tx.send_modify(|s| {
                let p = s.get_mut(pod);
                // The BLE layer is already chasing it: this is not idle, and
                // not a failure (FR-1.6).
                if p.state == PodState::Connected {
                    p.state = PodState::Reconnecting;
                }
                p.battery_percent = None;
            })
        }
        ClickEvent::Battery { percent } => {
            // Logged for its *timing*, not its value: how often a pod volunteers
            // one is what `STALE_AFTER` is guessing at, and one ride's log
            // settles it.
            tracing::debug!(pod = pod.as_str(), percent, "controller: battery");
            status_tx.send_modify(|s| s.get_mut(pod).battery_percent = Some(percent))
        }
        ClickEvent::Button { button, pressed } => {
            // The status is a readout of this *pod's* link — what it sent, and
            // how much — so it counts the raw event, before the merge below
            // decides whether the controller as a whole changed.
            status_tx.send_modify(|s| {
                let p = s.get_mut(pod);
                p.last_button = Some(button_name(button));
                if pressed {
                    p.buttons_seen = p.buttons_seen.saturating_add(1);
                    // A pod that sends its own paddle has proved which one it
                    // is. That it *also* relays its twin's is normal (see
                    // `Buttons`), so the other paddle is evidence of a
                    // wrong-way-round pair only while this pod has never sent
                    // its own — otherwise the screen would ask to swap a pair
                    // that is filed correctly, and swapping would break it.
                    if button == pod.paddle() {
                        p.confirmed = true;
                        p.contradicted = false;
                    } else if button == pod.other().paddle() && !p.confirmed {
                        p.contradicted = true;
                    }
                }
            });
            // Which pod said what, before the merge collapses it. A pod
            // reporting a button that belongs to the *other* one is invisible
            // downstream — the input carries the button, never its source — and
            // that is exactly the shape of a mis-mapped bit.
            tracing::debug!(
                pod = pod.as_str(),
                button = button_name(button),
                pressed,
                "controller: button"
            );

            // One press is one press, however many pods reported it.
            let Some(pressed) = buttons.edge(pod, button, pressed) else {
                return;
            };
            // A send failure only means nobody is listening yet.
            let _ = input_tx.send(ControllerInput {
                button: button_name(button),
                pressed,
                pod: pod.into(),
            });
        }
        ClickEvent::Unknown { kind, raw } => {
            // The bytes, not just the type. A frame we cannot read is the one
            // case where the type byte is the least useful part of it — it is
            // very often the *framing* that is wrong rather than the content,
            // and without the payload there is nothing to tell the two apart
            // (NFR-8).
            tracing::debug!(
                pod = pod.as_str(),
                raw = %raw.iter().map(|b| format!("{b:02x}")).collect::<String>(),
                "controller: unhandled frame type 0x{kind:02x}"
            )
        }
        // Terminal, and it has to drop the client as well as change the status,
        // so `handle_event` deals with it before we ever get here.
        ClickEvent::GaveUp { .. } => {}
    }
}

/// Exchange everything the two slots know about their pods, leaving each
/// status's own identity (`pod`, `symbol`) where it is.
fn swap_status(s: &mut ControllerStatus) {
    std::mem::swap(&mut s.minus.state, &mut s.plus.state);
    std::mem::swap(&mut s.minus.address, &mut s.plus.address);
    std::mem::swap(&mut s.minus.name, &mut s.plus.name);
    std::mem::swap(&mut s.minus.battery_percent, &mut s.plus.battery_percent);
    std::mem::swap(&mut s.minus.error, &mut s.plus.error);
    std::mem::swap(&mut s.minus.last_button, &mut s.plus.last_button);
    std::mem::swap(&mut s.minus.buttons_seen, &mut s.plus.buttons_seen);
    std::mem::swap(&mut s.minus.confirmed, &mut s.plus.confirmed);
    // A swap made *because* of a contradiction must not carry the complaint
    // across, or the screen would go on asking to be swapped back.
    s.minus.contradicted = false;
    s.plus.contradicted = false;
}

/// Close both links, waiting for each (SAF-9). In-flight attempts are abandoned
/// rather than finished — a quit must not wait out a twenty-second scan.
async fn shutdown_all(minus: &mut Slot, plus: &mut Slot) {
    for slot in [minus, plus] {
        slot.abandon_attempt();
        slot.events = None;
        slot.last_seen = None;
        if let Some(client) = slot.client.take() {
            client.shutdown().await;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_button_has_a_distinct_webview_name() {
        let mut seen = std::collections::HashSet::new();
        for b in Button::ALL {
            let name = button_name(b);
            assert!(seen.insert(name), "{name} is used twice");
            // The webview switches on these; a stray `+` would need escaping.
            assert!(
                name.chars().all(|c| c.is_ascii_lowercase()),
                "{name} is not a plain lowercase identifier"
            );
        }
        assert_eq!(seen.len(), 10);
    }

    #[test]
    fn paddles_are_named_for_typescript_not_for_display() {
        assert_eq!(button_name(Button::Plus), "plus");
        assert_eq!(button_name(Button::Minus), "minus");
    }

    #[test]
    fn a_fresh_status_has_two_idle_pods_and_blames_neither() {
        let s = ControllerStatus::default();
        assert_eq!(s.minus.state, PodState::Idle);
        assert_eq!(s.plus.state, PodState::Idle);
        assert_eq!(s.minus.symbol, "−");
        assert_eq!(s.plus.symbol, "+");
        assert!(!s.any_connected() && !s.both_connected());
        assert!(s.minus.error.is_none() && s.plus.battery_percent.is_none());
    }

    #[test]
    fn one_pod_connecting_does_not_speak_for_the_other() {
        // The reason this module has two slots: a single status meant a
        // connected pod and a missing one shared one line, and the rider could
        // not tell which of the two had gone (FR-1.4).
        let mut s = ControllerStatus::default();
        s.get_mut(PodId::Plus).state = PodState::Connected;
        assert!(s.any_connected());
        assert!(!s.both_connected());
        assert!(!s.minus.connected());
        assert_eq!(s.get(PodId::Minus).state, PodState::Idle);
    }

    #[test]
    fn chasing_a_pod_is_bounded_but_patient() {
        // FR-1.11. A Click sleeps between efforts and only advertises after a
        // button press (A-4), so it must be chased for longer than a trainer —
        // but not forever: a flat pod never comes back, and scanning for it
        // every thirty seconds competes with the trainer for the one adapter.
        let cfg = controller_config();
        assert_eq!(cfg.backoff.max_attempts, Some(RECONNECT_ATTEMPTS));
        assert!(cfg.backoff.exhausted(RECONNECT_ATTEMPTS));
        assert!(!cfg.backoff.exhausted(RECONNECT_ATTEMPTS - 1));
        // That it outlasts the trainer's budget is asserted at compile time,
        // next to the constant.
    }

    #[test]
    fn a_known_address_beats_the_type_byte_and_survives_a_swap() {
        let mut status = ControllerStatus::default();
        // Nothing seen yet: fall back to the advertisement's type byte.
        assert_eq!(
            selector_for(PodId::Minus, None, &status, false),
            PodSelector::Pod(PodId::Minus)
        );
        // Swapped, so the − slot goes looking for the byte we used to read as +.
        assert_eq!(
            selector_for(PodId::Minus, None, &status, true),
            PodSelector::Pod(PodId::Plus)
        );

        // Once a pod has been seen, its address identifies it exactly — and an
        // address cannot be confused with its identically-named twin.
        status.get_mut(PodId::Minus).address = Some("f4:c4:59:03:a1:8e".into());
        assert_eq!(
            selector_for(PodId::Minus, None, &status, true),
            PodSelector::Address("f4:c4:59:03:a1:8e".into())
        );
        // An address handed in by the caller wins over the remembered one.
        assert_eq!(
            selector_for(
                PodId::Minus,
                Some("c0:4a:0e:f9:a8:78".into()),
                &status,
                false
            ),
            PodSelector::Address("c0:4a:0e:f9:a8:78".into())
        );
        // A blank one is not an address.
        assert_eq!(
            selector_for(PodId::Plus, Some("  ".into()), &status, false),
            PodSelector::Pod(PodId::Plus)
        );
    }

    /// One pod's edge, folded in exactly as the supervisor would.
    fn press(
        pod: PodId,
        button: Button,
        pressed: bool,
        buttons: &mut Buttons,
        status_tx: &watch::Sender<ControllerStatus>,
        input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
    ) {
        apply(
            pod,
            ClickEvent::Button { button, pressed },
            buttons,
            status_tx,
            input_tx,
        );
    }

    /// Everything the app was told about, in order.
    fn drain(
        inputs: &mut tokio::sync::broadcast::Receiver<ControllerInput>,
    ) -> Vec<(&'static str, bool)> {
        let mut out = Vec::new();
        while let Ok(i) = inputs.try_recv() {
            out.push((i.button, i.pressed));
        }
        out
    }

    /// The bug this merge exists to kill, in the shape it was reported: one
    /// press of `+` shifted two gears, while `−` shifted one. The `+` paddle is
    /// relayed by the twin as well as sent by its own pod (§2.3.1), and the app
    /// acted on both copies.
    #[test]
    fn one_press_of_a_paddle_both_pods_report_is_one_press() {
        let (status_tx, _status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, mut inputs) = tokio::sync::broadcast::channel(16);
        let mut b = Buttons::default();

        // Its own pod sends it; the twin relays the same press.
        press(PodId::Plus, Button::Plus, true, &mut b, &status_tx, &input_tx);
        press(PodId::Minus, Button::Plus, true, &mut b, &status_tx, &input_tx);
        assert_eq!(drain(&mut inputs), vec![("plus", true)], "one press, one shift");

        // And the release only lands once both have let go — the aggregate is
        // what re-arms the next press.
        press(PodId::Plus, Button::Plus, false, &mut b, &status_tx, &input_tx);
        assert!(drain(&mut inputs).is_empty(), "the twin still holds it");
        press(PodId::Minus, Button::Plus, false, &mut b, &status_tx, &input_tx);
        assert_eq!(drain(&mut inputs), vec![("plus", false)]);

        // The next press is a new press, not a suppressed duplicate.
        press(PodId::Plus, Button::Plus, true, &mut b, &status_tx, &input_tx);
        assert_eq!(drain(&mut inputs), vec![("plus", true)]);
    }

    #[test]
    fn a_button_only_one_pod_reports_still_arrives() {
        // `−` and the D-pad were never doubled, and must not now be swallowed.
        let (status_tx, _status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, mut inputs) = tokio::sync::broadcast::channel(16);
        let mut b = Buttons::default();

        for _ in 0..3 {
            press(PodId::Minus, Button::Minus, true, &mut b, &status_tx, &input_tx);
            press(PodId::Minus, Button::Minus, false, &mut b, &status_tx, &input_tx);
        }
        assert_eq!(
            drain(&mut inputs),
            vec![
                ("minus", true),
                ("minus", false),
                ("minus", true),
                ("minus", false),
                ("minus", true),
                ("minus", false)
            ],
            "three presses of − are three downshifts"
        );
    }

    #[test]
    fn two_buttons_at_once_are_still_two_buttons() {
        // The mask is simultaneous state, so the merge has to be per button and
        // not a single "something is held" flag.
        let (status_tx, _status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, mut inputs) = tokio::sync::broadcast::channel(16);
        let mut b = Buttons::default();

        press(PodId::Minus, Button::Up, true, &mut b, &status_tx, &input_tx);
        press(PodId::Minus, Button::Minus, true, &mut b, &status_tx, &input_tx);
        assert_eq!(drain(&mut inputs), vec![("up", true), ("minus", true)]);
    }

    /// A release that never arrives would latch a paddle held forever, and a
    /// latched paddle does not shift at all — worse than the doubling.
    #[test]
    fn a_pod_that_goes_quiet_does_not_latch_the_button_it_was_holding() {
        let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, mut inputs) = tokio::sync::broadcast::channel(16);
        let mut b = Buttons::default();
        let mut slot = Slot::default();

        press(PodId::Plus, Button::Plus, true, &mut b, &status_tx, &input_tx);
        let _ = drain(&mut inputs);

        // Falling behind the pod may have cost us the release.
        handle_event(
            PodId::Plus,
            Err(tokio::sync::broadcast::error::RecvError::Lagged(4)),
            &mut slot,
            &mut b,
            &status_tx,
            &input_tx,
        );
        assert_eq!(drain(&mut inputs), vec![("plus", false)], "let go of it");

        press(PodId::Plus, Button::Plus, true, &mut b, &status_tx, &input_tx);
        assert_eq!(drain(&mut inputs), vec![("plus", true)], "shifting still works");
        assert_eq!(status_rx.borrow().plus.buttons_seen, 2);
    }

    /// A pod flipped to `Reconnecting` for going quiet must come back the
    /// moment it says anything. Without this the flag was a one-way door: the
    /// card read "Reconnecting…" for the rest of the ride while the paddle
    /// shifted perfectly, which is indistinguishable from losing the pod.
    #[test]
    fn a_quiet_pod_that_speaks_again_is_connected_again() {
        let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, _) = tokio::sync::broadcast::channel(8);
        let mut slot = Slot::default();
        let mut buttons = Buttons::default();

        // What the staleness sweep does to a pod nobody has pressed.
        status_tx.send_modify(|s| {
            let p = s.get_mut(PodId::Minus);
            p.state = PodState::Reconnecting;
        });

        handle_event(
            PodId::Minus,
            Ok(ClickEvent::Battery { percent: 90 }),
            &mut slot,
            &mut buttons,
            &status_tx,
            &input_tx,
        );

        assert_eq!(status_rx.borrow().minus.state, PodState::Connected);
        assert_eq!(status_rx.borrow().minus.battery_percent, Some(90));
    }

    #[test]
    fn the_link_reporting_its_own_end_is_not_taken_as_proof_of_life() {
        // `Disconnected` arrives on the same stream as everything else, so the
        // rule above has to exclude it or a drop would announce itself as a
        // recovery.
        let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, _) = tokio::sync::broadcast::channel(8);
        let mut slot = Slot::default();
        let mut buttons = Buttons::default();
        status_tx.send_modify(|s| s.get_mut(PodId::Plus).state = PodState::Connected);

        handle_event(
            PodId::Plus,
            Ok(ClickEvent::Disconnected),
            &mut slot,
            &mut buttons,
            &status_tx,
            &input_tx,
        );
        assert_eq!(status_rx.borrow().plus.state, PodState::Reconnecting);
    }

    #[test]
    fn a_pod_is_not_called_stale_before_a_rider_could_plausibly_shift_twice() {
        // The window this replaced was 20 s, which is shorter than an ordinary
        // gap between shifts on a flat road — so a working pod was declared
        // lost for not being pressed.
        assert!(
            STALE_AFTER >= Duration::from_secs(120),
            "a Click is quiet by nature: {STALE_AFTER:?} will fire on a healthy pod"
        );
    }

    #[test]
    fn a_swap_relabels_the_pods_without_releasing_what_is_held() {
        let mut b = Buttons::default();
        assert_eq!(b.edge(PodId::Minus, Button::Plus, true), Some(true));
        std::mem::swap(&mut b.minus, &mut b.plus);
        assert!(b.held(Button::Plus), "a swap is a relabelling, not a release");
        // And the release still lands, now arriving over the slot it moved to.
        assert_eq!(b.edge(PodId::Plus, Button::Plus, false), Some(false));
    }

    #[test]
    fn pressing_a_paddle_proves_which_pod_it_is() {
        let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, mut inputs) = tokio::sync::broadcast::channel(8);
        let mut buttons = Buttons::default();

        apply(
            PodId::Plus,
            ClickEvent::Button {
                button: Button::Plus,
                pressed: true,
            },
            &mut buttons,
            &status_tx,
            &input_tx,
        );
        let plus = status_rx.borrow().plus.clone();
        assert!(plus.confirmed, "its own paddle settles it");
        assert!(!plus.contradicted);
        assert_eq!(plus.last_button, Some("plus"));
        assert_eq!(plus.buttons_seen, 1);

        // The edge still reaches the webview, tagged with its pod.
        let forwarded = inputs.try_recv().expect("edge forwarded");
        assert_eq!(forwarded.button, "plus");
        assert_eq!(forwarded.pod, Pod::Plus);

        // The other pod's paddle arriving on a pod that has already sent its own
        // is not evidence of anything: the pair relays each other's paddles
        // (§2.3.1, `Buttons`). Complaining here asked the rider to swap a pair
        // that was filed correctly.
        apply(
            PodId::Plus,
            ClickEvent::Button {
                button: Button::Minus,
                pressed: true,
            },
            &mut buttons,
            &status_tx,
            &input_tx,
        );
        assert!(!status_rx.borrow().plus.contradicted);
    }

    #[test]
    fn a_pod_that_has_only_ever_sent_the_other_paddle_is_still_worth_querying() {
        // The case the flag is actually for: nothing has confirmed this pod, and
        // the one paddle it has sent belongs to its twin.
        let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, _) = tokio::sync::broadcast::channel(8);
        let mut buttons = Buttons::default();

        apply(
            PodId::Plus,
            ClickEvent::Button {
                button: Button::Minus,
                pressed: true,
            },
            &mut buttons,
            &status_tx,
            &input_tx,
        );
        assert!(status_rx.borrow().plus.contradicted);
        assert!(!status_rx.borrow().plus.confirmed);

        // Its own paddle settles it and withdraws the complaint.
        apply(
            PodId::Plus,
            ClickEvent::Button {
                button: Button::Plus,
                pressed: true,
            },
            &mut buttons,
            &status_tx,
            &input_tx,
        );
        assert!(status_rx.borrow().plus.confirmed);
        assert!(!status_rx.borrow().plus.contradicted);
    }

    #[test]
    fn releases_do_not_count_as_evidence() {
        let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, _) = tokio::sync::broadcast::channel(8);
        apply(
            PodId::Minus,
            ClickEvent::Button {
                button: Button::Minus,
                pressed: false,
            },
            &mut Buttons::default(),
            &status_tx,
            &input_tx,
        );
        let minus = status_rx.borrow().minus.clone();
        assert_eq!(minus.buttons_seen, 0);
        assert!(!minus.confirmed);
    }

    #[test]
    fn a_swap_exchanges_what_the_pods_know_and_clears_the_complaint() {
        let mut s = ControllerStatus::default();
        s.minus.address = Some("aa".into());
        s.minus.state = PodState::Connected;
        s.minus.battery_percent = Some(80);
        s.minus.contradicted = true;
        s.plus.state = PodState::GaveUp;

        swap_status(&mut s);

        // Identity stays put; everything the link knew moves.
        assert_eq!(s.minus.pod, Pod::Minus);
        assert_eq!(s.minus.symbol, "−");
        assert_eq!(s.plus.address.as_deref(), Some("aa"));
        assert_eq!(s.plus.state, PodState::Connected);
        assert_eq!(s.plus.battery_percent, Some(80));
        assert_eq!(s.minus.state, PodState::GaveUp);
        assert!(!s.plus.contradicted && !s.minus.contradicted);
    }

    #[test]
    fn a_missing_pod_is_reported_as_something_to_do_not_as_not_found() {
        let advice = connect_advice(PodId::Minus, &FtmsError::NotFound("the − Click pod".into()));
        assert!(advice.contains('−'), "{advice}");
        assert!(advice.to_lowercase().contains("press"), "{advice}");
    }

    #[test]
    fn giving_up_is_terminal_and_forgets_the_battery_but_not_the_pod() {
        // Distinct from `Disconnected`, which means "hold on, retrying". This
        // one means the app has stopped trying, and saying nothing would leave
        // the rider with dead buttons and no explanation (FR-1.11).
        let mut status = ControllerStatus::default();
        let p = status.get_mut(PodId::Plus);
        p.state = PodState::Connected;
        p.battery_percent = Some(80);
        p.address = Some("c0:4a:0e:f9:a8:78".into());

        let (status_tx, status_rx) = watch::channel(status);
        let (input_tx, _) = tokio::sync::broadcast::channel(4);
        let mut slot = Slot::default();
        handle_event(
            PodId::Plus,
            Ok(ClickEvent::GaveUp { attempts: 30 }),
            &mut slot,
            &mut Buttons::default(),
            &status_tx,
            &input_tx,
        );

        let plus = status_rx.borrow().plus.clone();
        assert_eq!(plus.state, PodState::GaveUp);
        assert!(
            plus.battery_percent.is_none(),
            "a stale battery reading is a lie"
        );
        assert!(plus.error.as_deref().is_some_and(|e| e.contains("Gave up")));
        // The address is how the next attempt finds this pod rather than its
        // twin, so it outlives the link.
        assert_eq!(plus.address.as_deref(), Some("c0:4a:0e:f9:a8:78"));
        // And the other pod is untouched: one pod dying is not both.
        assert_eq!(status_rx.borrow().minus.state, PodState::Idle);
    }

    #[test]
    fn a_dropped_link_reads_as_reconnecting_not_as_gone() {
        let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
        let (input_tx, _) = tokio::sync::broadcast::channel(4);
        status_tx.send_modify(|s| s.get_mut(PodId::Minus).state = PodState::Connected);

        apply(
            PodId::Minus,
            ClickEvent::Disconnected,
            &mut Buttons::default(),
            &status_tx,
            &input_tx,
        );
        assert_eq!(status_rx.borrow().minus.state, PodState::Reconnecting);
        assert!(
            status_rx.borrow().minus.error.is_none(),
            "retrying is not a failure"
        );
    }

    #[test]
    fn a_pod_the_scan_sees_is_connected_unless_the_rider_said_otherwise() {
        // A Click advertises for a couple of seconds after a button press, so
        // the scan sighting *is* the connect opportunity — there is no second
        // one to press a button for.
        let mut slot = Slot::default();
        assert!(slot.auto && slot.idle(), "a fresh slot takes what it is offered");

        // A deliberate disconnect stops that: a disconnect that reconnects
        // itself two seconds later is not a disconnect.
        slot.auto = false;
        assert!(slot.idle());
        assert!(!slot.auto);

        // Asking again by hand re-arms it — which is what `Cmd::Connect` does.
        slot.auto = true;
        assert!(slot.auto);

        // A slot mid-search is not idle, so a sighting cannot start a second
        // attempt for the same pod.
        let (cancel, _rx) = oneshot::channel();
        slot.cancel = Some(cancel);
        assert!(!slot.idle());
    }

    #[test]
    fn a_superseded_attempt_is_recognisable_by_its_generation() {
        // Two requests in flight for one pod would otherwise both land, and the
        // second link would be left open with nothing holding it (SAF-9).
        let mut slot = Slot::default();
        slot.generation += 1;
        let stale = slot.generation;
        slot.abandon_attempt();
        assert_ne!(stale, slot.generation);
        assert!(!slot.connecting());
    }
}
