//! Device discovery and connection state (FR-1, FR-9.1–9.3).
//!
//! A real BLE scanner. A background task drives `bikecontrol_ble::scan` and
//! publishes its results on a `watch` channel; [`DeviceRegistry`] — which lives
//! inside the app's synchronous `Mutex` and therefore may never `.await` —
//! reads that channel, merges in the trainer supervisor's status, and produces
//! the `DeviceInfo` list the UI renders.
//!
//! ```text
//!   scan task ──watch<ScanSnapshot>──┐
//!                                    ├──► DeviceRegistry::poll ──► DeviceInfo[]
//!   trainer   ──watch<TrainerStatus>─┘
//! ```
//!
//! The behaviour that matters, and the reason `control_acquired` is a field
//! rather than a state: **BLE connection and FTMS control acquisition are
//! separate steps** (FR-9.3). A trainer goes `Connecting → Connected →
//! Controlling`, and it can sit at `Connected` indefinitely if the control
//! point is refused.

use std::collections::{HashMap, HashSet};
use std::time::Duration;

use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
use bikecontrol_ble::uuids;
use bikecontrol_ble::PodId;
use bikecontrol_core::types::ConnectionState;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use uuid::Uuid;

use crate::controller::{ControllerHandle, PodState};
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
use crate::trainer::{TrainerHandle, TrainerStatus};

/// One pass of the scanner. Long enough for a trainer to advertise, short
/// enough that the list feels live.
const SCAN_WINDOW: Duration = Duration::from_millis(2500);
/// Poll interval while scanning is switched off.
const IDLE_POLL: Duration = Duration::from_millis(400);
/// How long to wait before looking for the adapter again. Longer than the scan
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
const ADAPTER_RETRY: Duration = Duration::from_secs(2);

/// What to suggest when there is no adapter. The remedy is platform-specific
/// and telling an Android rider to check BlueZ is worse than saying nothing.
#[cfg(target_os = "android")]
const ADAPTER_HINT: &str = "Check Bluetooth is switched on and BikeControl is allowed to use it.";
/// Shown when we *know* the radio is off, which is a different message: there is
/// one specific thing to do and `request_bluetooth_enable` will offer to do it.
#[cfg(target_os = "android")]
const BLUETOOTH_OFF: &str = "Bluetooth is switched off.";
#[cfg(target_os = "linux")]
const ADAPTER_HINT: &str = "Check the radio is on and BlueZ is running.";
#[cfg(not(any(target_os = "android", target_os = "linux")))]
const ADAPTER_HINT: &str = "Check the radio is on.";
/// Heart Rate Service, so an HRM in the room is labelled rather than "unknown".
const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805f9b34fb);

/// What we think a peripheral is, from its advertised services and
/// manufacturer data (FR-1.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DeviceKind {
    /// Advertises FTMS (`0x1826`).
    Trainer,
    /// A Click pod, named for the shift paddle it carries — the type byte in
    /// its manufacturer data says which (§2.3.1, FR-1.4).
    ClickMinus,
    ClickPlus,
    HeartRate,
    Unknown,
}

impl DeviceKind {
    /// Which Click pod this row is, if it is one at all.
    pub fn pod_id(self) -> Option<PodId> {
        match self {
            DeviceKind::ClickMinus => Some(PodId::Minus),
            DeviceKind::ClickPlus => Some(PodId::Plus),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceInfo {
    pub id: String,
    pub name: String,
    pub address: String,
    /// dBm. Roughly −40 (touching) to −95 (barely there). 0 when the adapter
    /// did not report one.
    pub rssi: i16,
    pub kind: DeviceKind,
    pub state: ConnectionState,
    /// FTMS control point acquired (FR-2.1). **Connected ≠ controllable**
    /// (FR-9.3) — this is deliberately a separate field, not a state.
    pub control_acquired: bool,
    pub services: Vec<String>,
    /// Previously paired, so it would auto-connect on launch (FR-1.5).
    pub remembered: bool,
    pub battery_pct: Option<u8>,
    /// Live reading from a connected heart rate monitor — the row's proof that
    /// data is actually flowing, the way a pod's battery is.
    pub heart_rate_bpm: Option<u8>,
    // No unlock countdown. FR-3.9 assumed the Click v2 needed its Zwift session
    // refreshing daily; TASK-0 disproved it on this hardware — the pods answer
    // `RideOn 00 09` unencrypted, with no key exchange and no expiry (§2.3.1).
    // The field was always `None`, which the UI rendered as "expired" against a
    // pod that was working perfectly.
    /// Human-readable failure, shown verbatim in the UI (FR-9.2).
    pub error: Option<String>,
}

/// Outcome of one registry tick.
pub struct PollResult {
    pub changed: bool,
    /// Devices whose connection state settled this tick.
    pub transitions: Vec<DeviceInfo>,
    /// Trainer status, when it changed since the last poll. The caller turns
    /// this into user-facing notices (FR-1.8, FR-9.4).
    pub trainer_changed: Option<TrainerStatus>,
    /// Heart rate link status, when it changed since the last poll — same
    /// contract as `trainer_changed`.
    pub hr_changed: Option<HeartRateStatus>,
}

/// What the scan task publishes.
#[derive(Debug, Clone, Default)]
pub struct ScanSnapshot {
    pub devices: Vec<DiscoveredDevice>,
    /// Adapter-level failure — no radio, BlueZ down. Surfaced verbatim.
    pub error: Option<String>,
    /// Bumped every completed pass, so `poll` can tell "same devices" from
    /// "scanner has not run yet".
    pub generation: u64,
}

pub struct DeviceRegistry {
    trainer: TrainerHandle,
    /// Held so a Click row in the device list connects the same way its card on
    /// the connection screen does — one path, not two that can disagree.
    controller: ControllerHandle,
    /// The heart rate supervisor, for the same reason.
    hr: HeartRateHandle,
    scan_rx: watch::Receiver<ScanSnapshot>,
    scan_on: watch::Sender<bool>,
    forgotten: HashSet<String>,
    remembered: HashSet<String>,
    /// The list published last tick, for change detection.
    published: Vec<DeviceInfo>,
    last_trainer: TrainerStatus,
    last_hr: HeartRateStatus,
    pub scanning: bool,
    /// Scanning was switched off by *us*, to get out of the way of a connect —
    /// not by the rider. Only a suspension is resumed automatically (FR-1.12).
    scan_suspended: bool,
    /// The most recent adapter error, so the UI can say why the list is empty.
    pub error: Option<String>,
}

impl DeviceRegistry {
    pub fn new(trainer: TrainerHandle, controller: ControllerHandle, hr: HeartRateHandle) -> Self {
        let (scan_on, scan_on_rx) = watch::channel(false);
        let (scan_tx, scan_rx) = watch::channel(ScanSnapshot::default());
        tauri::async_runtime::spawn(scan_loop(scan_on_rx, scan_tx));
        Self {
            last_trainer: trainer.status(),
            last_hr: hr.status(),
            trainer,
            controller,
            hr,
            scan_rx,
            scan_on,
            forgotten: HashSet::new(),
            remembered: HashSet::new(),
            published: Vec::new(),
            scanning: false,
            scan_suspended: false,
            error: None,
        }
    }

    /// Rider-initiated. Cancels any suspension: an explicit request outranks
    /// our own bookkeeping in both directions.
    pub fn start_scan(&mut self) {
        self.scan_suspended = false;
        self.set_scanning(true);
    }

    pub fn stop_scan(&mut self) {
        self.scan_suspended = false;
        self.set_scanning(false);
    }

    fn set_scanning(&mut self, on: bool) {
        self.scanning = on;
        let _ = self.scan_on.send(on);
    }

    /// Rebuild the device list from the scanner and the trainer supervisor.
    pub fn poll(&mut self) -> PollResult {
        let trainer = self.trainer.status();
        let trainer_changed = (trainer != self.last_trainer).then(|| trainer.clone());
        self.last_trainer = trainer.clone();

        let hr = self.hr.status();
        // Only *link* changes are reported upward: a bpm arriving once a second
        // would otherwise raise a notice per heartbeat. The row still updates —
        // `build` reads the full status every tick.
        let hr_changed = (hr.state != self.last_hr.state || hr.error != self.last_hr.error)
            .then(|| hr.clone());
        self.last_hr = hr;

        // The scan is switched off for the duration of a connect so that it and
        // `find_peripheral` do not fight over the one adapter — and nothing else
        // ever turns it back on. A disconnect, or a connect that failed, would
        // otherwise leave the list frozen on a snapshot taken before the attempt
        // and the rider with no way to find anything but the Scan button
        // (FR-1.12, NFR-7).
        if should_resume_scan(self.scan_suspended, &trainer) {
            self.scan_suspended = false;
            self.set_scanning(true);
        }

        let next = self.build(&trainer);
        let transitions = state_transitions(&self.published, &next);
        let changed = next != self.published;
        self.published = next;

        PollResult {
            changed,
            transitions,
            trainer_changed,
            hr_changed,
        }
    }

    /// Merge the scan snapshot with the trainer's live status.
    fn build(&mut self, trainer: &TrainerStatus) -> Vec<DeviceInfo> {
        let snapshot = self.scan_rx.borrow().clone();
        self.error = snapshot.error.clone();

        let controller = self.controller.status();

        let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
        for d in &snapshot.devices {
            let id = d.address.clone();
            if self.forgotten.contains(&id) {
                continue;
            }
            let kind = classify(d);
            // A Click advertises for a few seconds after a button press and
            // then goes back to sleep (A-4). Nobody can reliably press Connect
            // inside that window, and there is nothing to decide anyway — this
            // is the pod the rider already told us about by pressing a button
            // on it. So the scan connects it (FR-1.5), unless they disconnected
            // it on purpose, in which case the supervisor ignores this.
            if let Some(pod) = kind.pod_id() {
                self.controller.pod_seen(pod, &id);
            }
            out.push(DeviceInfo {
                kind,
                name: d.label(),
                address: d.address.clone(),
                rssi: d.rssi.unwrap_or(0),
                state: if self.scanning {
                    ConnectionState::Scanning
                } else {
                    ConnectionState::Idle
                },
                control_acquired: false,
                services: d.services.iter().map(|u| describe_service(*u)).collect(),
                remembered: self.remembered.contains(&id),
                battery_pct: None,
                heart_rate_bpm: None,
                error: None,
                id,
            });
        }

        // A connected peripheral usually stops appearing in scan results, and
        // the trainer must not vanish from the list the moment it is in use.
        if let Some(address) = trainer.address.clone() {
            let existing = out.iter().position(|d| d.id == address);
            let idx = match existing {
                Some(i) => i,
                None => {
                    out.push(DeviceInfo {
                        id: address.clone(),
                        name: trainer.name.clone().unwrap_or_else(|| "Trainer".into()),
                        address,
                        rssi: 0,
                        kind: DeviceKind::Trainer,
                        state: ConnectionState::Idle,
                        control_acquired: false,
                        services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
                        remembered: true,
                        battery_pct: None,
                        heart_rate_bpm: None,
                        error: None,
                    });
                    out.len() - 1
                }
            };
            let device = &mut out[idx];
            device.kind = DeviceKind::Trainer;
            device.state = trainer.state.clone();
            device.control_acquired = trainer.control_acquired;
            device.remembered = true;
            device.error = trainer.error.clone().or_else(|| {
                trainer
                    .stale
                    .then(|| "Connected but sending no data — pedal to wake it".to_string())
            });
            if let Some(name) = &trainer.name {
                device.name = name.clone();
            }
        }

        // The same for each Click pod, and for the same reason: a connected pod
        // stops advertising, and a row that disappears the moment the pod works
        // reads as a pod that has gone (FR-1.4). This is a *view* of what the
        // controller supervisor owns — the panel above the list and this row
        // are the same link, never two.
        for id in PodId::BOTH {
            let pod = controller.get(id);
            let Some(address) = pod.address.clone() else {
                continue;
            };
            if self.forgotten.contains(&address) {
                continue;
            }
            let kind = match id {
                PodId::Minus => DeviceKind::ClickMinus,
                PodId::Plus => DeviceKind::ClickPlus,
            };
            let idx = match out.iter().position(|d| d.id == address) {
                Some(i) => i,
                None => {
                    out.push(DeviceInfo {
                        id: address.clone(),
                        name: pod.name.clone().unwrap_or_else(|| "Zwift Click".into()),
                        address,
                        rssi: 0,
                        kind,
                        state: ConnectionState::Idle,
                        control_acquired: false,
                        services: vec![describe_service(ZWIFT_SERVICE)],
                        remembered: true,
                        battery_pct: None,
                        heart_rate_bpm: None,
                        error: None,
                    });
                    out.len() - 1
                }
            };
            let device = &mut out[idx];
            device.kind = kind;
            device.battery_pct = pod.battery_percent;
            device.error = pod.error.clone();
            device.remembered = true;
            device.state = match pod.state {
                PodState::Connected => ConnectionState::Connected,
                PodState::Searching => ConnectionState::Connecting,
                PodState::Reconnecting => ConnectionState::Reconnecting,
                PodState::GaveUp => ConnectionState::Lost {
                    reason: "stopped answering".into(),
                },
                // Nothing has been asked of this pod, so whatever the scan says
                // about it stands.
                PodState::Idle => device.state.clone(),
            };
        }

        // And the heart rate monitor, for the same reason again: a connected
        // monitor stops advertising, and its row must not vanish the moment it
        // starts working. A view of what the supervisor owns, never a second
        // link.
        let hr = self.hr.status();
        if let Some(address) = hr.address.clone() {
            if !self.forgotten.contains(&address) {
                let idx = match out.iter().position(|d| d.id == address) {
                    Some(i) => i,
                    None => {
                        out.push(DeviceInfo {
                            id: address.clone(),
                            name: hr
                                .name
                                .clone()
                                .unwrap_or_else(|| "Heart rate monitor".into()),
                            address,
                            rssi: 0,
                            kind: DeviceKind::HeartRate,
                            state: ConnectionState::Idle,
                            control_acquired: false,
                            services: vec![describe_service(HEART_RATE_SERVICE)],
                            remembered: true,
                            battery_pct: None,
                            heart_rate_bpm: None,
                            error: None,
                        });
                        out.len() - 1
                    }
                };
                let device = &mut out[idx];
                device.kind = DeviceKind::HeartRate;
                device.battery_pct = hr.battery_percent;
                device.heart_rate_bpm = hr.bpm;
                device.error = hr.error.clone();
                device.remembered = true;
                if let Some(name) = &hr.name {
                    device.name = name.clone();
                }
                // Idle after a deliberate disconnect keeps whatever the scan
                // says about the row, exactly like an idle pod.
                if hr.state != ConnectionState::Idle {
                    device.state = hr.state.clone();
                }
            }
        }

        // Trainers first, then by signal strength: the thing the rider is
        // looking for should not be below an unnamed peripheral.
        out.sort_by(|a, b| {
            (a.kind != DeviceKind::Trainer)
                .cmp(&(b.kind != DeviceKind::Trainer))
                .then(b.rssi.cmp(&a.rssi))
                .then(a.id.cmp(&b.id))
        });
        out
    }

    pub fn list(&self) -> Vec<DeviceInfo> {
        self.published.clone()
    }

    pub fn get(&self, id: &str) -> Option<DeviceInfo> {
        self.published.iter().find(|d| d.id == id).cloned()
    }

    pub fn connect(&mut self, id: &str) -> Result<DeviceInfo, String> {
        let device = self
            .get(id)
            .ok_or_else(|| format!("no such device: {id}"))?;

        // A Click row connects the pod it *is*, by address. Routed to the
        // controller supervisor rather than handled here, so the device list
        // and the connection screen drive the same one link per pod (FR-1.4).
        if let Some(pod) = device.kind.pod_id() {
            self.remembered.insert(id.to_string());
            self.forgotten.remove(id);
            self.controller.connect(pod, Some(device.address.clone()));
            let mut info = device;
            info.state = ConnectionState::Connecting;
            info.error = None;
            info.remembered = true;
            return Ok(info);
        }

        // A heart rate row connects the monitor it is, by address — routed to
        // the heart rate supervisor so this list and whatever else drives the
        // link stay one path.
        if device.kind == DeviceKind::HeartRate {
            self.remembered.insert(id.to_string());
            self.forgotten.remove(id);
            self.hr.connect(Some(device.address.clone()));
            let mut info = device;
            info.state = ConnectionState::Connecting;
            info.error = None;
            info.remembered = true;
            return Ok(info);
        }

        if device.kind != DeviceKind::Trainer {
            return Err(format!(
                "{} is not a trainer, Click pod or heart rate monitor — there is nothing to \
                 connect to.",
                device.name
            ));
        }

        // A second click on an already-connected trainer must not tear the
        // working session down and start over. Connecting takes ~8 s against
        // this hardware, which is easily long enough for an impatient rider to
        // click again and destroy the connection they were waiting for.
        let status = self.trainer.status();
        if status.address.as_deref() == Some(device.address.as_str()) {
            match status.state {
                ConnectionState::Connecting | ConnectionState::Scanning => {
                    return Err(format!("Already connecting to {}…", device.name))
                }
                ConnectionState::Reconnecting => {
                    return Err(format!("Reconnecting to {} — hold on.", device.name))
                }
                ConnectionState::Connected | ConnectionState::Controlling => {
                    return Err(format!(
                        "{} is already connected. Disconnect first to start over.",
                        device.name
                    ))
                }
                ConnectionState::Idle | ConnectionState::Lost { .. } => {}
            }
        }
        // Our own scan and the client's `find_peripheral` would otherwise fight
        // over the one adapter. Suspended, not stopped: `poll` puts it back as
        // soon as the trainer is no longer attached (FR-1.12).
        self.set_scanning(false);
        self.scan_suspended = true;
        self.remembered.insert(id.to_string());
        self.forgotten.remove(id);
        self.trainer
            .connect(scan::TrainerSelector::Address(device.address.clone()));

        let mut info = device;
        info.state = ConnectionState::Connecting;
        info.control_acquired = false;
        info.error = None;
        info.remembered = true;
        Ok(info)
    }

    pub fn disconnect(&mut self, id: &str) -> Result<DeviceInfo, String> {
        let mut device = self
            .get(id)
            .ok_or_else(|| format!("no such device: {id}"))?;
        if device.kind == DeviceKind::Trainer {
            // SAF-2 runs inside the supervisor before the link drops.
            self.trainer.disconnect();
        }
        if let Some(pod) = device.kind.pod_id() {
            self.controller.disconnect(Some(pod));
        }
        if device.kind == DeviceKind::HeartRate {
            self.hr.disconnect();
        }
        device.state = ConnectionState::Idle;
        device.control_acquired = false;
        Ok(device)
    }

    pub fn forget(&mut self, id: &str) -> Result<(), String> {
        let device = self
            .get(id)
            .ok_or_else(|| format!("no such device: {id}"))?;
        if device.kind == DeviceKind::Trainer && device.control_acquired {
            self.trainer.disconnect();
        }
        if device.kind == DeviceKind::HeartRate {
            self.hr.disconnect();
        }
        self.remembered.remove(id);
        self.forgotten.insert(id.to_string());
        self.published.retain(|d| d.id != id);
        Ok(())
    }

    /// The address of each Click pod the scanner has seen (FR-1.4).
    ///
    /// Connecting by address is both faster and unambiguous: the pods share a
    /// local name, so anything that goes looking for "a Zwift Click" is picking
    /// one of the two at random. The scan has already done the identifying
    /// work — this hands it to the controller supervisor rather than making it
    /// scan again.
    pub fn click_pod_addresses(&self) -> HashMap<PodId, String> {
        let mut out = HashMap::new();
        for device in &self.published {
            if let Some(pod) = device.kind.pod_id() {
                out.entry(pod).or_insert_with(|| device.address.clone());
            }
        }
        out
    }

    /// True once a trainer is connected *and* controllable — the precondition
    /// for a real ride (FR-2.1).
    pub fn trainer_controllable(&self) -> bool {
        self.trainer.status().controllable()
    }

    pub fn trainer_status(&self) -> TrainerStatus {
        self.trainer.status()
    }
}

/// FR-1.2. FTMS is checked first: the D100 advertises the Zwift custom service
/// too, so "is a Zwift device" is not enough to call something a Click.
fn classify(d: &DiscoveredDevice) -> DeviceKind {
    if d.is_fitness_machine() {
        return DeviceKind::Trainer;
    }
    if d.services.contains(&HEART_RATE_SERVICE) {
        return DeviceKind::HeartRate;
    }
    // Which pod comes from the manufacturer-data type byte (§2.3.1) — the one
    // thing that distinguishes the pair, since both advertise the same name.
    match d.pod_id() {
        Some(PodId::Minus) => return DeviceKind::ClickMinus,
        Some(PodId::Plus) => return DeviceKind::ClickPlus,
        // A Zwift device we cannot place: a v1 Click, or the trainer's own
        // Zwift service. Labelled by the service rather than guessed at.
        None if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() => {
            return DeviceKind::Unknown
        }
        None => {}
    }
    DeviceKind::Unknown
}

fn describe_service(uuid: Uuid) -> String {
    match uuids::well_known_name(uuid) {
        Some(name) => format!("{uuid}  ({name})"),
        None if uuid == ZWIFT_SERVICE => format!("{uuid}  (Zwift custom)"),
        None => uuid.to_string(),
    }
}

/// May a scan we suspended for a connect be switched back on?
///
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
/// without a radio. Only a suspension of *ours* is resumed — a rider who
/// pressed Stop scan meant it.
fn should_resume_scan(suspended: bool, trainer: &TrainerStatus) -> bool {
    suspended && !trainer.is_attached()
}

/// Devices whose connection state or control acquisition changed (FR-1.7).
fn state_transitions(before: &[DeviceInfo], after: &[DeviceInfo]) -> Vec<DeviceInfo> {
    after
        .iter()
        .filter(|d| match before.iter().find(|p| p.id == d.id) {
            None => d.state != ConnectionState::Scanning && d.state != ConnectionState::Idle,
            Some(prev) => prev.state != d.state || prev.control_acquired != d.control_acquired,
        })
        .cloned()
        .collect()
}

/// Drive the radio. Runs for the life of the process; a failure to get an
/// adapter is reported through the snapshot rather than killing the task, so
/// plugging a dongle in later recovers on its own (NFR-4).
async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot>) {
    let mut generation = 0u64;
    loop {
        if !*on.borrow() {
            // Wait to be switched on rather than spinning.
            if on.changed().await.is_err() {
                return;
            }
            continue;
        }

        // Android only: the Java backend is initialised from MainActivity and
        // calling btleplug before that has succeeded panics rather than erroring
        // (see `android::ready`). Bluetooth switched off at launch is enough to
        // land here; MainActivity retries on every resume, so this is a wait
        // rather than a dead end, and the rider gets told which knob to turn.
        #[cfg(target_os = "android")]
        if !crate::android::ready() || crate::android::bluetooth_enabled() == Some(false) {
            generation += 1;
            let _ = tx.send(ScanSnapshot {
                devices: Vec::new(),
                error: Some(BLUETOOTH_OFF.into()),
                generation,
            });
            tokio::time::sleep(ADAPTER_RETRY).await;
            continue;
        }

        let adapter = match scan::default_adapter().await {
            Ok(a) => a,
            Err(e) => {
                tracing::warn!(error = %e, "no Bluetooth adapter");
                generation += 1;
                let _ = tx.send(ScanSnapshot {
                    devices: Vec::new(),
                    error: Some(format!("{e}. {ADAPTER_HINT}")),
                    generation,
                });
                tokio::time::sleep(ADAPTER_RETRY).await;
                continue;
            }
        };

        // FR-1.1 lists every peripheral, not only fitness machines: a trainer
        // is not obliged to advertise FTMS, and the rider needs to see what is
        // in the room to know the scan is working at all.
        let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
        generation += 1;
        let snapshot = match result {
            Ok(devices) => ScanSnapshot {
                devices,
                error: None,
                generation,
            },
            Err(e) => {
                // Debug as well as Display, because the useful half of a BLE
                // failure is usually in the source chain that Display drops.
                // "bluetooth error: JNI call failed" was the *entire* symptom of
                // a detached-thread bug; the Debug form said
                // `Bluetooth(Other(JniCall(ThreadDetached)))` and would have
                // named it outright.
                tracing::warn!(error = %e, cause = ?e, "scan failed");
                ScanSnapshot {
                    devices: Vec::new(),
                    error: Some(e.to_string()),
                    generation,
                }
            }
        };
        let _ = tx.send(snapshot);
        tokio::time::sleep(IDLE_POLL).await;
    }
}

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

    fn info(id: &str, state: ConnectionState, control: bool) -> DeviceInfo {
        DeviceInfo {
            id: id.into(),
            name: id.into(),
            address: id.into(),
            rssi: -50,
            kind: DeviceKind::Trainer,
            state,
            control_acquired: control,
            services: Vec::new(),
            remembered: false,
            battery_pct: None,
            heart_rate_bpm: None,
            error: None,
        }
    }

    #[test]
    fn acquiring_control_is_a_transition_even_though_the_state_is_unchanged() {
        // FR-9.3: Connected → Controlling *and* Connected-with-control are both
        // events the UI must see.
        let before = vec![info("t", ConnectionState::Connected, false)];
        let after = vec![info("t", ConnectionState::Connected, true)];
        let t = state_transitions(&before, &after);
        assert_eq!(t.len(), 1);
        assert!(t[0].control_acquired);
    }

    #[test]
    fn a_device_merely_appearing_in_a_scan_is_not_a_transition() {
        let after = vec![info("t", ConnectionState::Scanning, false)];
        assert!(state_transitions(&[], &after).is_empty());
        let after = vec![info("t", ConnectionState::Idle, false)];
        assert!(state_transitions(&[], &after).is_empty());
    }

    #[test]
    fn a_device_that_appears_already_connected_is_a_transition() {
        let after = vec![info("t", ConnectionState::Controlling, true)];
        assert_eq!(state_transitions(&[], &after).len(), 1);
    }

    #[test]
    fn a_lost_link_is_reported() {
        let before = vec![info("t", ConnectionState::Controlling, true)];
        let after = vec![info(
            "t",
            ConnectionState::Lost {
                reason: "gone".into(),
            },
            false,
        )];
        let t = state_transitions(&before, &after);
        assert_eq!(t.len(), 1);
        assert!(!t[0].control_acquired);
    }

    #[test]
    fn an_unchanged_list_produces_no_transitions() {
        let list = vec![info("t", ConnectionState::Controlling, true)];
        assert!(state_transitions(&list, &list).is_empty());
    }

    #[test]
    fn a_scan_suspended_for_a_connect_comes_back_when_the_connect_is_over() {
        // FR-1.12. Connecting switches the scan off so it does not fight
        // `find_peripheral` over the one adapter, and before this nothing ever
        // switched it back on: a disconnect or a failed connect left the device
        // list frozen on a snapshot taken before the attempt.
        let idle = TrainerStatus::default();
        let lost = TrainerStatus {
            state: ConnectionState::Lost {
                reason: "gone".into(),
            },
            ..TrainerStatus::default()
        };
        assert!(should_resume_scan(true, &idle));
        assert!(should_resume_scan(true, &lost));

        // Still in progress, or in use: leave the adapter alone.
        let connecting = TrainerStatus {
            state: ConnectionState::Connecting,
            ..TrainerStatus::default()
        };
        let reconnecting = TrainerStatus {
            state: ConnectionState::Reconnecting,
            ..TrainerStatus::default()
        };
        let riding = TrainerStatus {
            state: ConnectionState::Controlling,
            control_acquired: true,
            ..TrainerStatus::default()
        };
        assert!(!should_resume_scan(true, &connecting));
        assert!(!should_resume_scan(true, &reconnecting));
        assert!(!should_resume_scan(true, &riding));

        // The rider pressed Stop scan. That is not ours to undo.
        assert!(!should_resume_scan(false, &idle));
    }

    #[test]
    fn well_known_services_are_named_and_zwift_is_recognised() {
        let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
        assert!(ftms.contains("Fitness Machine"), "{ftms}");
        let zwift = describe_service(ZWIFT_SERVICE);
        assert!(zwift.contains("Zwift"), "{zwift}");
    }
}
