//! The FTMS trainer client: an async actor that owns the BLE peripheral.
//!
//! Everything device-facing happens in one task. Callers hold an [`FtmsClient`]
//! handle, which is cheap to clone-by-reference and drives the actor over a
//! channel. That structure is what makes the safety requirements enforceable:
//! there is exactly one place that writes to the control point, so the rate
//! limit (FR-2.8), the clamp (SAF-3), the acknowledgement tracking (FR-2.7,
//! SAF-4) and the shutdown reset (SAF-2) cannot be bypassed.
//!
//! ```text
//!   caller ──set_target()──► [cmd channel] ──► actor ──write──► trainer
//!   caller ◄──broadcast────── telemetry ◄──── actor ◄─notify──── trainer
//! ```
//!
//! The FTMS connection sequence and the "request control before anything else"
//! rule follow `obostjancic/smart-trainer-control` (`src/lib/bike/bluetooth-bike.ts`),
//! MIT licensed, Copyright (c) 2025 Ogi. See REQUIREMENTS.md §3.2.

use std::collections::VecDeque;
use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};

use bikecontrol_core::types::{ConnectionState, ControlTarget, SafetyLimits, Telemetry};
use btleplug::api::{Characteristic, Peripheral as _, ValueNotification, WriteType};
use btleplug::platform::{Adapter, Peripheral};
use futures::StreamExt;
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use uuid::Uuid;

use crate::capabilities::{
    self, FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange,
    TrainerCapabilities,
};
use crate::control_point::{self as cp, OpCode, ResultCode, SimulationParameters, StopOrPause};
use crate::error::FtmsError;
use crate::indoor_bike_data::{self, hex};
use crate::scan::{self, TrainerSelector};
use crate::uuids;
use crate::zwift;

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

/// Exponential backoff for auto-reconnect (FR-1.6).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Backoff {
    pub initial: Duration,
    pub max: Duration,
    pub multiplier: f64,
    /// `None` means retry forever — the right default for a ride in progress,
    /// since the rider may simply have stopped pedalling and let the trainer
    /// sleep (A-4).
    pub max_attempts: Option<u32>,
}

impl Default for Backoff {
    fn default() -> Self {
        Self {
            initial: Duration::from_millis(500),
            max: Duration::from_secs(30),
            multiplier: 2.0,
            max_attempts: None,
        }
    }
}

impl Backoff {
    /// Delay before attempt number `attempt` (0-based). Pure and total: no
    /// overflow, no panic, saturating at [`Backoff::max`].
    pub fn delay(&self, attempt: u32) -> Duration {
        if attempt == 0 {
            return self.initial.min(self.max);
        }
        let factor = self.multiplier.max(1.0).powi(attempt.min(32) as i32);
        let millis = self.initial.as_secs_f64() * 1000.0 * factor;
        if !millis.is_finite() || millis >= self.max.as_millis() as f64 {
            self.max
        } else {
            Duration::from_millis(millis as u64)
        }
    }

    pub fn exhausted(&self, attempts: u32) -> bool {
        self.max_attempts.is_some_and(|m| attempts >= m)
    }
}

/// Tunables for [`FtmsClient`].
#[derive(Debug, Clone)]
pub struct FtmsConfig {
    /// Applied at the point of transmission, on every path (SAF-3).
    pub limits: SafetyLimits,
    /// Minimum gap between control point writes. 250 ms == 4 Hz (FR-2.8).
    pub min_write_interval: Duration,
    /// How long to wait for a control point indication before treating the
    /// write as unacknowledged (FR-2.7).
    pub ack_timeout: Duration,
    /// How long to scan for the trainer before giving up.
    pub scan_timeout: Duration,
    pub backoff: Backoff,
    /// Prefer `SetIndoorBikeSimulationParameters` (`0x11`) over
    /// `SetTargetInclination` (`0x03`) for gradient. Defaults to `false`
    /// because A-1 is unresolved and the reference implementation uses `0x03`.
    pub use_simulation_mode: bool,
    /// Rolling/aero coefficients accompanying the grade in simulation mode.
    pub simulation_template: SimulationParameters,
    /// Consecutive unacknowledged writes before the control path halts (SAF-4).
    pub max_consecutive_failures: u32,
    /// Capacity of the telemetry and event broadcast channels.
    pub channel_capacity: usize,
    /// Issue `StartOrResume` (`0x07`) once control is acquired.
    pub start_on_connect: bool,
    /// Ignore the Target Setting Features bitfield when deciding whether a
    /// target may be sent.
    ///
    /// **For protocol discovery only.** `probe set` uses it to write `0x11`
    /// even when the trainer does not advertise bit 13, which is the only way
    /// to answer open question A-1: advertised support and actual support are
    /// not the same thing. Leave `false` in the app — FR-2.6 exists so the
    /// rider never sees an unexplained silent failure.
    pub ignore_advertised_features: bool,
}

impl Default for FtmsConfig {
    fn default() -> Self {
        Self {
            limits: SafetyLimits::default(),
            min_write_interval: Duration::from_millis(250),
            ack_timeout: Duration::from_secs(2),
            scan_timeout: Duration::from_secs(15),
            backoff: Backoff::default(),
            use_simulation_mode: false,
            simulation_template: SimulationParameters::default(),
            max_consecutive_failures: 5,
            channel_capacity: 256,
            start_on_connect: true,
            ignore_advertised_features: false,
        }
    }
}

// ---------------------------------------------------------------------------
// Public surface
// ---------------------------------------------------------------------------

/// What happened to a [`FtmsClient::set_target`] call.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ControlOutcome {
    /// The trainer acknowledged the write with `Success`. `sent` is the value
    /// after clamping, which may differ from what was asked for.
    Acknowledged { sent: ControlTarget },
    /// A newer target arrived before this one was transmitted, so it was
    /// dropped by the rate limiter (FR-2.8). Not an error: for a streaming
    /// target, the newest value is the only one that matters.
    Superseded,
}

/// A one-shot FTMS procedure that is not a target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Procedure {
    RequestControl,
    Reset,
    Start,
    Stop,
    Pause,
}

impl Procedure {
    fn encode(self) -> (OpCode, Vec<u8>) {
        match self {
            Procedure::RequestControl => (OpCode::RequestControl, cp::request_control()),
            Procedure::Reset => (OpCode::Reset, cp::reset()),
            Procedure::Start => (OpCode::StartOrResume, cp::start_or_resume()),
            Procedure::Stop => (OpCode::StopOrPause, cp::stop_or_pause(StopOrPause::Stop)),
            Procedure::Pause => (OpCode::StopOrPause, cp::stop_or_pause(StopOrPause::Pause)),
        }
    }
}

/// Everything the client reports. Telemetry is also available on its own
/// dedicated channel; it is duplicated here so a diagnostic consumer (the probe,
/// a debug log) can see one ordered stream (NFR-8).
#[derive(Debug, Clone, PartialEq)]
pub enum FtmsEvent {
    State(ConnectionState),
    Telemetry(Telemetry),
    Capabilities(TrainerCapabilities),
    /// A control point indication was received.
    ControlResponse { op: Option<OpCode>, result: ResultCode },
    /// Fitness Machine Status (`0x2ADA`) notification, raw.
    MachineStatus { raw: String },
    /// A packet arrived that could not be decoded. Reported, never fatal
    /// (NFR-4).
    DecodeFailure {
        characteristic: Uuid,
        raw: String,
        error: String,
    },
    /// SAF-4: the trainer stopped acknowledging writes. The control path is
    /// halted and the rider must be told.
    ControlFault { reason: String },
}

/// Handle to a connected trainer.
///
/// Dropping the handle closes the command channel, which makes the actor run
/// the SAF-2 shutdown sequence (zero gradient, minimum resistance, `Reset`) and
/// disconnect. Prefer [`FtmsClient::shutdown`], which waits for that sequence to
/// finish — a drop cannot, and a runtime torn down immediately afterwards will
/// not give the actor a chance to run.
#[derive(Debug)]
pub struct FtmsClient {
    cmd_tx: mpsc::Sender<Command>,
    state_rx: watch::Receiver<ConnectionState>,
    caps_rx: watch::Receiver<TrainerCapabilities>,
    telemetry_tx: broadcast::Sender<Telemetry>,
    events_tx: broadcast::Sender<FtmsEvent>,
    address: String,
    name: Option<String>,
}

impl FtmsClient {
    /// Scan for, connect to and take control of a trainer.
    ///
    /// Returns only once the trainer has acknowledged `RequestControl`, so a
    /// successful return means "controllable", not merely "connected"
    /// (FR-9.3).
    pub async fn connect(
        selector: TrainerSelector,
        config: FtmsConfig,
    ) -> Result<Self, FtmsError> {
        let adapter = scan::default_adapter().await?;
        Self::connect_with_adapter(adapter, selector, config).await
    }

    /// As [`FtmsClient::connect`], but abandoned as soon as `cancel` resolves.
    ///
    /// Returns `Ok(None)` when it was cancelled. A connect runs for up to
    /// [`FtmsConfig::scan_timeout`] before the trainer has even been found, and
    /// a caller that needs to stop — the app is quitting, the rider pressed
    /// disconnect — cannot wait that out (FR-1.10, SAF-8). Any link the
    /// abandoned attempt had already opened is closed before this returns, so
    /// the trainer is free for the next launch (A-3).
    pub async fn connect_cancellable(
        selector: TrainerSelector,
        config: FtmsConfig,
        cancel: impl Future<Output = ()>,
    ) -> Result<Option<Self>, FtmsError> {
        let in_flight = InFlight::default();
        let outcome = {
            let attempt = async {
                let adapter = scan::default_adapter().await?;
                Self::connect_on(adapter, selector, config, &in_flight).await
            };
            tokio::pin!(attempt, cancel);
            tokio::select! {
                result = &mut attempt => Some(result),
                () = &mut cancel => None,
            }
        };
        match outcome {
            Some(result) => result.map(Some),
            None => {
                tracing::info!("connect attempt cancelled");
                in_flight.abandon().await;
                Ok(None)
            }
        }
    }

    /// As [`FtmsClient::connect`], but on a caller-supplied adapter.
    pub async fn connect_with_adapter(
        adapter: Adapter,
        selector: TrainerSelector,
        config: FtmsConfig,
    ) -> Result<Self, FtmsError> {
        Self::connect_on(adapter, selector, config, &InFlight::default()).await
    }

    async fn connect_on(
        adapter: Adapter,
        selector: TrainerSelector,
        config: FtmsConfig,
        in_flight: &InFlight,
    ) -> Result<Self, FtmsError> {
        let (state_tx, state_rx) = watch::channel(ConnectionState::Scanning);
        let (caps_tx, caps_rx) = watch::channel(TrainerCapabilities::default());
        let (telemetry_tx, _) = broadcast::channel(config.channel_capacity);
        let (events_tx, _) = broadcast::channel(config.channel_capacity);
        let (cmd_tx, cmd_rx) = mpsc::channel(64);

        let connected = connect_session(&adapter, &selector, &config, &state_tx, in_flight).await?;

        let address = connected.address.clone();
        let name = connected.name.clone();
        let _ = caps_tx.send(connected.capabilities);
        let _ = events_tx.send(FtmsEvent::Capabilities(connected.capabilities));
        set_state(&state_tx, &events_tx, ConnectionState::Controlling);

        let actor = Actor {
            adapter,
            selector,
            config,
            session: Some(connected.session),
            capabilities: connected.capabilities,
            state_tx,
            caps_tx,
            telemetry_tx: telemetry_tx.clone(),
            events_tx: events_tx.clone(),
            pending: None,
            queued_target: None,
            queued_procedures: VecDeque::new(),
            last_write: None,
            consecutive_failures: 0,
            halted: false,
            ride_start: Instant::now(),
            last_health_check: Instant::now(),
            zwift_cadence: None,
        };

        tokio::spawn(actor.run(cmd_rx, Some(connected.notifications)));

        Ok(Self {
            cmd_tx,
            state_rx,
            caps_rx,
            telemetry_tx,
            events_tx,
            address,
            name,
        })
    }

    /// The trainer's address.
    pub fn address(&self) -> &str {
        &self.address
    }

    /// The trainer's advertised name, if it had one.
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    /// Current connection state (FR-1.7).
    pub fn state(&self) -> ConnectionState {
        self.state_rx.borrow().clone()
    }

    /// Watch connection state changes.
    pub fn state_stream(&self) -> watch::Receiver<ConnectionState> {
        self.state_rx.clone()
    }

    /// What the trainer reported it supports (FR-2.6).
    pub fn capabilities(&self) -> TrainerCapabilities {
        *self.caps_rx.borrow()
    }

    pub fn capabilities_stream(&self) -> watch::Receiver<TrainerCapabilities> {
        self.caps_rx.clone()
    }

    /// Subscribe to decoded telemetry (FR-2.2).
    ///
    /// This is a broadcast channel: a slow consumer lags rather than blocking
    /// the BLE task (NFR-2).
    pub fn telemetry(&self) -> broadcast::Receiver<Telemetry> {
        self.telemetry_tx.subscribe()
    }

    /// Subscribe to the full diagnostic event stream (NFR-8).
    pub fn events(&self) -> broadcast::Receiver<FtmsEvent> {
        self.events_tx.subscribe()
    }

    /// Send a control target.
    ///
    /// The value is clamped to the configured safety limits *and* to the range
    /// the trainer reported (SAF-3, FR-2.6), rate-limited to
    /// [`FtmsConfig::min_write_interval`] (FR-2.8), and the returned future
    /// resolves when the trainer acknowledges it or the write is superseded by
    /// a newer target (FR-2.7).
    pub async fn set_target(&self, target: ControlTarget) -> Result<ControlOutcome, FtmsError> {
        let (reply, rx) = oneshot::channel();
        self.cmd_tx
            .send(Command::SetTarget { target, reply })
            .await
            .map_err(|_| FtmsError::ClientShutDown)?;
        rx.await.map_err(|_| FtmsError::ClientShutDown)?
    }

    /// Re-acquire control (`0x00`). Rarely needed — [`FtmsClient::connect`]
    /// already does it, and so does every reconnect.
    pub async fn request_control(&self) -> Result<(), FtmsError> {
        self.procedure(Procedure::RequestControl).await
    }

    /// `StartOrResume` (`0x07`).
    pub async fn start(&self) -> Result<(), FtmsError> {
        self.procedure(Procedure::Start).await
    }

    /// `StopOrPause` (`0x08`) with the Pause parameter.
    pub async fn pause(&self) -> Result<(), FtmsError> {
        self.procedure(Procedure::Pause).await
    }

    /// `StopOrPause` (`0x08`) with the Stop parameter.
    pub async fn stop(&self) -> Result<(), FtmsError> {
        self.procedure(Procedure::Stop).await
    }

    /// `Reset` (`0x01`) — returns the machine to its defaults.
    pub async fn reset(&self) -> Result<(), FtmsError> {
        self.procedure(Procedure::Reset).await
    }

    pub async fn procedure(&self, kind: Procedure) -> Result<(), FtmsError> {
        let (reply, rx) = oneshot::channel();
        self.cmd_tx
            .send(Command::Procedure { kind, reply })
            .await
            .map_err(|_| FtmsError::ClientShutDown)?;
        rx.await.map_err(|_| FtmsError::ClientShutDown)?
    }

    /// Run the SAF-2 shutdown sequence and disconnect, waiting for it to
    /// complete.
    ///
    /// Always prefer this to simply dropping the client at the end of a ride or
    /// on application exit: it is the difference between "the trainer is left
    /// at zero" and "probably".
    pub async fn shutdown(self) -> Result<(), FtmsError> {
        self.shutdown_ref().await
    }

    /// As [`FtmsClient::shutdown`], but by reference.
    ///
    /// A caller that shares the client — an `Arc<FtmsClient>` behind a
    /// supervisor, so control writes can be spawned without stalling the
    /// telemetry pump — cannot consume it, and SAF-2 must not depend on being
    /// able to. Idempotent: shutting an already-stopped client down is a no-op.
    pub async fn shutdown_ref(&self) -> Result<(), FtmsError> {
        let (reply, rx) = oneshot::channel();
        if self
            .cmd_tx
            .send(Command::Shutdown { reply })
            .await
            .is_err()
        {
            // The actor is already gone; it ran its cleanup on the way out.
            return Ok(());
        }
        let _ = rx.await;
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Actor
// ---------------------------------------------------------------------------

#[derive(Debug)]
enum Command {
    SetTarget {
        target: ControlTarget,
        reply: oneshot::Sender<Result<ControlOutcome, FtmsError>>,
    },
    Procedure {
        kind: Procedure,
        reply: oneshot::Sender<Result<(), FtmsError>>,
    },
    Shutdown {
        reply: oneshot::Sender<()>,
    },
}

/// Upper bound on one write in the SAF-2 reset sequence.
const SAFETY_WRITE_TIMEOUT: Duration = Duration::from_millis(750);
/// Total budget for the SAF-2 *writes*, leaving room inside NFR-9's eight
/// seconds for the disconnect that follows. Individually bounded writes are not
/// enough on their own: five of them plus their pacing can still outlast a
/// caller holding the app's exit open. The sequence zeroes the active targets
/// first (see [`safety_reset_commands`]), so a truncated run has still left the
/// trainer at minimum load — which is the part SAF-2 actually cares about.
const SAFETY_SEQUENCE_BUDGET: Duration = Duration::from_secs(4);
/// Upper bound on closing a link, whether deliberately or after a cancelled
/// attempt. A disconnect that hangs must not be the reason the app will not
/// quit (NFR-9).
pub(crate) const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(2);

/// The peripheral a connect attempt is working on right now.
///
/// A cancelled attempt (FR-1.10) may already have opened a GATT link, and a
/// cancelled future cannot hand anything back to its caller — hence the shared
/// slot. Walking away from that link without closing it matters because the
/// D100 accepts one host (A-3): the difference is between "quit and relaunch
/// fixed it" and "power-cycle the trainer".
#[derive(Clone, Default)]
pub(crate) struct InFlight(Arc<std::sync::Mutex<Option<Peripheral>>>);

impl InFlight {
    fn lock(&self) -> std::sync::MutexGuard<'_, Option<Peripheral>> {
        self.0.lock().unwrap_or_else(|e| e.into_inner())
    }

    /// Take responsibility for a link the attempt is about to open.
    pub(crate) fn hold(&self, peripheral: Peripheral) {
        *self.lock() = Some(peripheral);
    }

    /// The attempt finished and its outcome owns the link now — successfully,
    /// or by having disconnected on its own error path.
    pub(crate) fn released(&self) {
        let _ = self.lock().take();
    }

    /// Close a link left half-open by a cancelled attempt (SAF-8).
    pub(crate) async fn abandon(&self) {
        let Some(peripheral) = self.lock().take() else {
            return;
        };
        tracing::info!("closing the half-open link from a cancelled connect attempt");
        match tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await {
            Ok(Ok(())) => {}
            Ok(Err(e)) => tracing::warn!(error = %e, "abandoned link would not close"),
            Err(_) => tracing::warn!("abandoned link did not close in time"),
        }
    }
}

/// The characteristics we need on a connected peripheral.
struct Session {
    peripheral: Peripheral,
    control_point: Characteristic,
    #[allow(dead_code)]
    indoor_bike_data: Characteristic,
}

struct ConnectedTrainer {
    session: Session,
    capabilities: TrainerCapabilities,
    notifications: mpsc::Receiver<ValueNotification>,
    address: String,
    name: Option<String>,
}

/// A control point write awaiting its indication.
struct Pending {
    op: OpCode,
    deadline: Instant,
    reply: Reply,
}

enum Reply {
    Target {
        sent: ControlTarget,
        tx: oneshot::Sender<Result<ControlOutcome, FtmsError>>,
    },
    Unit(oneshot::Sender<Result<(), FtmsError>>),
}

struct Actor {
    adapter: Adapter,
    selector: TrainerSelector,
    config: FtmsConfig,
    session: Option<Session>,
    capabilities: TrainerCapabilities,
    state_tx: watch::Sender<ConnectionState>,
    caps_tx: watch::Sender<TrainerCapabilities>,
    telemetry_tx: broadcast::Sender<Telemetry>,
    events_tx: broadcast::Sender<FtmsEvent>,
    pending: Option<Pending>,
    queued_target: Option<(
        ControlTarget,
        oneshot::Sender<Result<ControlOutcome, FtmsError>>,
    )>,
    queued_procedures: VecDeque<(Procedure, oneshot::Sender<Result<(), FtmsError>>)>,
    last_write: Option<Instant>,
    consecutive_failures: u32,
    halted: bool,
    ride_start: Instant,
    last_health_check: Instant,
    /// Latest cadence from the trainer's Zwift channel, and when it arrived.
    ///
    /// This trainer declares no cadence in its Fitness Machine Feature bits and
    /// sends none in Indoor Bike Data, so FTMS alone cannot supply it — see
    /// [`crate::zwift::RidingData`]. Riding data arrives at about 1 Hz against
    /// Indoor Bike Data's ~5 Hz, so it is held here and stamped onto the faster
    /// stream rather than published as telemetry of its own.
    zwift_cadence: Option<(f32, Instant)>,
}

const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(3);
/// How long a cadence from the Zwift channel stays believable.
///
/// Generous against its ~1 Hz rate, but finite: a rider who stops pedalling
/// must read as stopped, and a channel that dies must not leave the last
/// cadence under the ride forever — with speed taken from cadence × gear, a
/// stale value is a ride that keeps rolling on its own.
const ZWIFT_CADENCE_TTL: Duration = Duration::from_secs(4);

impl Actor {
    async fn run(
        mut self,
        mut cmd_rx: mpsc::Receiver<Command>,
        mut notifications: Option<mpsc::Receiver<ValueNotification>>,
    ) {
        let mut shutdown_reply: Option<oneshot::Sender<()>> = None;

        'outer: loop {
            if self.session.is_none() {
                match self.reconnect(&mut cmd_rx).await {
                    Reconnected::Ok(rx) => {
                        notifications = Some(rx);
                        continue;
                    }
                    Reconnected::Shutdown(reply) => {
                        shutdown_reply = reply;
                        break 'outer;
                    }
                    Reconnected::GaveUp => break 'outer,
                }
            }

            let delay = self.next_delay();
            tokio::select! {
                // Telemetry first: NFR-2 requires notifications to be drained at
                // the trainer's native rate without backlog, and commands are
                // rate-limited to 4 Hz anyway so they can afford to wait.
                biased;

                notification = next_notification(&mut notifications) => match notification {
                    Some(n) => self.handle_notification(n),
                    None => {
                        self.on_link_lost("notification stream ended");
                        notifications = None;
                        continue;
                    }
                },

                cmd = cmd_rx.recv() => match cmd {
                    Some(Command::Shutdown { reply }) => {
                        shutdown_reply = Some(reply);
                        break 'outer;
                    }
                    Some(cmd) => self.handle_command(cmd),
                    // Every handle dropped: run the safety sequence anyway.
                    None => break 'outer,
                },

                _ = tokio::time::sleep(delay) => {}
            }

            self.service().await;
        }

        self.safety_shutdown().await;
        self.fail_all(|| FtmsError::ClientShutDown);
        if let Some(reply) = shutdown_reply {
            let _ = reply.send(());
        }
        tracing::info!("FTMS client stopped");
    }

    fn elapsed_ms(&self) -> u64 {
        self.ride_start.elapsed().as_millis() as u64
    }

    fn set_state(&self, state: ConnectionState) {
        set_state(&self.state_tx, &self.events_tx, state);
    }

    // -- inbound ----------------------------------------------------------

    /// The Zwift-channel cadence, if one arrived recently enough to trust.
    fn fresh_zwift_cadence(&self) -> Option<f32> {
        self.zwift_cadence
            .filter(|(_, at)| at.elapsed() < ZWIFT_CADENCE_TTL)
            .map(|(rpm, _)| rpm)
    }

    fn handle_notification(&mut self, n: ValueNotification) {
        if zwift::is_zwift_uuid(n.uuid) {
            // Any characteristic in the trainer's Zwift service, not just the
            // one the Click uses — which one a trainer talks on is undocumented.
            // Only riding-data frames (type 0x03) mean anything; the channel
            // carries other traffic and `decode_riding_data` rejects all of it.
            match zwift::decode_riding_data(&n.value) {
                Some(data) => {
                    tracing::debug!(
                        uuid = %n.uuid,
                        raw = %hex(&n.value),
                        cadence_rpm = data.cadence_rpm(),
                        power_w = data.power_w,
                        "zwift riding data"
                    );
                    self.zwift_cadence = Some((data.cadence_rpm(), Instant::now()));
                }
                // Logged, not swallowed. If cadence never arrives, the question
                // is whether the channel is silent or merely speaking a dialect
                // we do not decode, and only these lines can tell the two apart.
                None => tracing::debug!(
                    uuid = %n.uuid,
                    raw = %hex(&n.value),
                    "zwift frame that is not riding data"
                ),
            }
            return;
        }
        if n.uuid == uuids::INDOOR_BIKE_DATA {
            tracing::trace!(raw = %hex(&n.value), "0x2AD2 indoor bike data");
            match indoor_bike_data::decode(&n.value) {
                Ok(data) => {
                    if data.consumed < n.value.len() {
                        tracing::debug!(
                            raw = %hex(&n.value),
                            consumed = data.consumed,
                            "indoor bike data had trailing bytes we do not understand"
                        );
                    }
                    let mut telemetry = data.to_telemetry(self.elapsed_ms());
                    // FTMS carries no cadence on this trainer; the Zwift
                    // channel does. Fill it in, but never overwrite a cadence
                    // FTMS did report — a trainer that declares one is the
                    // better authority on it.
                    if telemetry.cadence_rpm.is_none() {
                        telemetry.cadence_rpm = self.fresh_zwift_cadence();
                    }
                    let _ = self.telemetry_tx.send(telemetry);
                    let _ = self.events_tx.send(FtmsEvent::Telemetry(telemetry));
                }
                Err(e) => {
                    tracing::warn!(raw = %hex(&n.value), error = %e, "malformed indoor bike data");
                    let _ = self.events_tx.send(FtmsEvent::DecodeFailure {
                        characteristic: n.uuid,
                        raw: hex(&n.value),
                        error: e.to_string(),
                    });
                }
            }
        } else if n.uuid == uuids::FITNESS_MACHINE_CONTROL_POINT {
            tracing::debug!(raw = %hex(&n.value), "0x2AD9 control point indication");
            match cp::decode_response(&n.value) {
                Ok(resp) => {
                    let _ = self.events_tx.send(FtmsEvent::ControlResponse {
                        op: resp.request_op_code,
                        result: resp.result,
                    });
                    self.resolve_indication(resp.raw_request_op_code, resp.result);
                }
                Err(e) => {
                    tracing::warn!(raw = %hex(&n.value), error = %e, "malformed control point indication");
                    let _ = self.events_tx.send(FtmsEvent::DecodeFailure {
                        characteristic: n.uuid,
                        raw: hex(&n.value),
                        error: e.to_string(),
                    });
                }
            }
        } else if n.uuid == uuids::FITNESS_MACHINE_STATUS {
            tracing::debug!(raw = %hex(&n.value), "0x2ADA fitness machine status");
            let _ = self.events_tx.send(FtmsEvent::MachineStatus {
                raw: hex(&n.value),
            });
        } else {
            tracing::trace!(uuid = %n.uuid, raw = %hex(&n.value), "notification on an unexpected characteristic");
        }
    }

    fn resolve_indication(&mut self, raw_op: u8, result: ResultCode) {
        let Some(pending) = self.pending.take() else {
            tracing::debug!(
                op = format_args!("0x{raw_op:02x}"),
                %result,
                "control point indication with nothing in flight"
            );
            return;
        };

        if raw_op != pending.op.as_u8() {
            // Put it back: the answer we are waiting for may still arrive.
            tracing::warn!(
                expected = format_args!("0x{:02x}", pending.op.as_u8()),
                got = format_args!("0x{raw_op:02x}"),
                "control point answered a different op code"
            );
            self.pending = Some(pending);
            return;
        }

        if result.is_success() {
            self.consecutive_failures = 0;
            complete(pending.reply, Ok(()));
        } else {
            // An explicit rejection is an answer, not silence: it does not count
            // towards the SAF-4 "trainer stopped acknowledging" fault.
            self.consecutive_failures = 0;
            let op = pending.op;
            tracing::warn!(%op, %result, "trainer rejected a control point write");
            complete(pending.reply, Err(FtmsError::Rejected { op, result }));
        }
    }

    fn on_link_lost(&mut self, reason: &str) {
        tracing::warn!(reason, "trainer link lost");
        self.session = None;
        if let Some(pending) = self.pending.take() {
            complete(pending.reply, Err(FtmsError::NotConnected));
        }
        self.set_state(ConnectionState::Lost {
            reason: reason.to_string(),
        });
    }

    // -- outbound ---------------------------------------------------------

    fn handle_command(&mut self, cmd: Command) {
        match cmd {
            Command::SetTarget { target, reply } => {
                if self.halted {
                    let _ = reply.send(Err(FtmsError::ControlHalted {
                        failures: self.consecutive_failures,
                    }));
                    return;
                }
                // Coalesce: the newest target wins, the old one is superseded.
                if let Some((_, old)) = self.queued_target.replace((target, reply)) {
                    let _ = old.send(Ok(ControlOutcome::Superseded));
                }
            }
            Command::Procedure { kind, reply } => {
                if self.halted {
                    let _ = reply.send(Err(FtmsError::ControlHalted {
                        failures: self.consecutive_failures,
                    }));
                    return;
                }
                self.queued_procedures.push_back((kind, reply));
            }
            Command::Shutdown { .. } => unreachable!("handled in run()"),
        }
    }

    /// How long we may sleep before something needs doing.
    fn next_delay(&self) -> Duration {
        let now = Instant::now();
        let mut delay = HEALTH_CHECK_INTERVAL;

        if let Some(p) = &self.pending {
            delay = delay.min(p.deadline.saturating_duration_since(now));
        } else if !self.halted && self.has_work() {
            delay = delay.min(time_until_write_allowed(
                self.last_write,
                now,
                self.config.min_write_interval,
            ));
        }

        // Never spin: a zero delay would busy-loop the select.
        delay.max(Duration::from_millis(1))
    }

    fn has_work(&self) -> bool {
        self.queued_target.is_some() || !self.queued_procedures.is_empty()
    }

    /// Timeouts, health checks and the next rate-limited write.
    async fn service(&mut self) {
        let now = Instant::now();

        // 1. Acknowledgement timeout (FR-2.7).
        if let Some(p) = &self.pending {
            if now >= p.deadline {
                let pending = self.pending.take().expect("just checked");
                let op = pending.op;
                let timeout_ms = self.config.ack_timeout.as_millis() as u64;
                self.consecutive_failures += 1;
                tracing::warn!(
                    %op,
                    failures = self.consecutive_failures,
                    "control point write was not acknowledged"
                );
                complete(pending.reply, Err(FtmsError::Unacknowledged { op, timeout_ms }));
                self.check_fault();
            }
        }

        // 2. Link health. btleplug does not always end the notification stream
        //    on disconnect, so poll as well.
        if now.duration_since(self.last_health_check) >= HEALTH_CHECK_INTERVAL {
            self.last_health_check = now;
            if let Some(session) = &self.session {
                match session.peripheral.is_connected().await {
                    Ok(true) => {}
                    Ok(false) => {
                        self.on_link_lost("peripheral reports disconnected");
                        return;
                    }
                    Err(e) => {
                        self.on_link_lost(&format!("connection check failed: {e}"));
                        return;
                    }
                }
            }
        }

        // 3. Next write.
        if self.pending.is_some() || self.halted || self.session.is_none() {
            return;
        }
        if !write_allowed(self.last_write, now, self.config.min_write_interval) {
            return;
        }

        // Procedures first: RequestControl must precede targets.
        if let Some((kind, reply)) = self.queued_procedures.pop_front() {
            let (op, bytes) = kind.encode();
            self.write(op, bytes, Reply::Unit(reply)).await;
            return;
        }

        if let Some((target, reply)) = self.queued_target.take() {
            let caps = if self.config.ignore_advertised_features {
                TrainerCapabilities {
                    feature: None,
                    ..self.capabilities
                }
            } else {
                self.capabilities
            };
            let gated = capabilities::gate_target(
                &self.config.limits,
                &caps,
                target,
                self.config.use_simulation_mode,
            );
            let sent = match gated {
                Ok(t) => t,
                Err(e) => {
                    let _ = reply.send(Err(FtmsError::Unsupported(e)));
                    return;
                }
            };
            if sent != target {
                tracing::debug!(?target, ?sent, "target clamped before transmission (SAF-3)");
            }
            let (op, bytes) = capabilities::encode_target(
                sent,
                self.config.use_simulation_mode,
                self.config.simulation_template,
            );
            self.write(op, bytes, Reply::Target { sent, tx: reply }).await;
        }
    }

    async fn write(&mut self, op: OpCode, bytes: Vec<u8>, reply: Reply) {
        let Some(session) = &self.session else {
            complete(reply, Err(FtmsError::NotConnected));
            return;
        };
        tracing::debug!(%op, raw = %hex(&bytes), "control point write");
        self.last_write = Some(Instant::now());

        match session
            .peripheral
            .write(&session.control_point, &bytes, WriteType::WithResponse)
            .await
        {
            Ok(()) => {
                self.pending = Some(Pending {
                    op,
                    deadline: Instant::now() + self.config.ack_timeout,
                    reply,
                });
            }
            Err(e) => {
                tracing::warn!(%op, error = %e, "control point write failed");
                self.consecutive_failures += 1;
                complete(reply, Err(FtmsError::Bluetooth(e)));
                self.check_fault();
            }
        }
    }

    /// SAF-4: stop sending and alert once the trainer has gone quiet.
    fn check_fault(&mut self) {
        if self.halted || self.consecutive_failures < self.config.max_consecutive_failures {
            return;
        }
        self.halted = true;
        let reason = format!(
            "trainer stopped acknowledging control point writes after {} consecutive failures",
            self.consecutive_failures
        );
        tracing::error!(reason, "control halted (SAF-4)");
        let _ = self
            .events_tx
            .send(FtmsEvent::ControlFault { reason });
        let failures = self.consecutive_failures;
        self.fail_all(move || FtmsError::ControlHalted { failures });
    }

    fn fail_all(&mut self, error: impl Fn() -> FtmsError) {
        if let Some((_, reply)) = self.queued_target.take() {
            let _ = reply.send(Err(error()));
        }
        while let Some((_, reply)) = self.queued_procedures.pop_front() {
            let _ = reply.send(Err(error()));
        }
    }

    // -- reconnect (FR-1.6) -----------------------------------------------

    async fn reconnect(&mut self, cmd_rx: &mut mpsc::Receiver<Command>) -> Reconnected {
        self.set_state(ConnectionState::Reconnecting);
        self.fail_all(|| FtmsError::NotConnected);

        let mut attempt: u32 = 0;
        loop {
            if self.config.backoff.exhausted(attempt) {
                let reason = format!("could not find or connect to {}", self.selector.describe());
                tracing::error!(attempt, reason, "giving up on reconnect");
                self.set_state(ConnectionState::Lost { reason });
                return Reconnected::GaveUp;
            }

            let delay = self.config.backoff.delay(attempt);
            tracing::info!(attempt, ?delay, "waiting before reconnect attempt");

            // Stay responsive to shutdown while backing off.
            let sleep = tokio::time::sleep(delay);
            tokio::pin!(sleep);
            loop {
                tokio::select! {
                    biased;
                    cmd = cmd_rx.recv() => match cmd {
                        Some(Command::Shutdown { reply }) => return Reconnected::Shutdown(Some(reply)),
                        Some(Command::SetTarget { reply, .. }) => {
                            let _ = reply.send(Err(FtmsError::NotConnected));
                        }
                        Some(Command::Procedure { reply, .. }) => {
                            let _ = reply.send(Err(FtmsError::NotConnected));
                        }
                        None => return Reconnected::Shutdown(None),
                    },
                    _ = &mut sleep => break,
                }
            }

            attempt += 1;
            self.set_state(ConnectionState::Connecting);

            // The *attempt* has to be interruptible too, not just the backoff
            // sleep before it. It runs for up to `scan_timeout` plus connect,
            // discovery and handshake — far longer than the caller's shutdown
            // budget — and a shutdown that waits for it to finish is a shutdown
            // that skips SAF-2 (FR-1.10, SAF-8).
            let in_flight = InFlight::default();
            let outcome = {
                let connect = connect_session(
                    &self.adapter,
                    &self.selector,
                    &self.config,
                    &self.state_tx,
                    &in_flight,
                );
                tokio::pin!(connect);
                loop {
                    tokio::select! {
                        biased;
                        cmd = cmd_rx.recv() => match cmd {
                            Some(Command::Shutdown { reply }) => break Attempt::Abandoned(Some(reply)),
                            Some(Command::SetTarget { reply, .. }) => {
                                let _ = reply.send(Err(FtmsError::NotConnected));
                            }
                            Some(Command::Procedure { reply, .. }) => {
                                let _ = reply.send(Err(FtmsError::NotConnected));
                            }
                            None => break Attempt::Abandoned(None),
                        },
                        result = &mut connect => break Attempt::Finished(Box::new(result)),
                    }
                }
            };

            let result = match outcome {
                Attempt::Finished(result) => *result,
                Attempt::Abandoned(reply) => {
                    in_flight.abandon().await;
                    return Reconnected::Shutdown(reply);
                }
            };

            match result {
                Ok(connected) => {
                    self.session = Some(connected.session);
                    self.capabilities = connected.capabilities;
                    let _ = self.caps_tx.send(connected.capabilities);
                    self.pending = None;
                    self.consecutive_failures = 0;
                    self.halted = false;
                    self.last_write = None;
                    self.last_health_check = Instant::now();
                    self.set_state(ConnectionState::Controlling);
                    tracing::info!(attempt, "reconnected to the trainer");
                    return Reconnected::Ok(connected.notifications);
                }
                Err(e) => {
                    tracing::warn!(attempt, error = %e, "reconnect attempt failed");
                    self.set_state(ConnectionState::Reconnecting);
                }
            }
        }
    }

    // -- shutdown (SAF-2) --------------------------------------------------

    /// Leave the trainer safe: zero gradient, minimum resistance, `Reset`,
    /// `Stop`. Best effort — every step is bounded and failures are logged
    /// rather than propagated, because there is nothing left to propagate to.
    async fn safety_shutdown(&mut self) {
        if let Some(pending) = self.pending.take() {
            complete(pending.reply, Err(FtmsError::ClientShutDown));
        }

        let Some(session) = self.session.take() else {
            // FR-1.11: a give-up has already published `Lost` with the reason
            // it gave up for. `Idle` here would erase that and read as "never
            // connected", which is the one thing the rider must not be told
            // after the app spent minutes trying to get back.
            if !matches!(*self.state_tx.borrow(), ConnectionState::Lost { .. }) {
                self.set_state(ConnectionState::Idle);
            }
            return;
        };

        tracing::info!("running the SAF-2 shutdown sequence");
        let deadline = Instant::now() + SAFETY_SEQUENCE_BUDGET;
        for (op, bytes) in safety_reset_commands(
            &self.config.limits,
            &self.capabilities,
            self.config.use_simulation_mode,
            self.config.simulation_template,
        ) {
            if Instant::now() >= deadline {
                // The zeroing writes go first, so what is being skipped here is
                // `Reset`/`Stop` on a trainer that is already at minimum load.
                tracing::warn!(%op, "SAF-2 budget spent; skipping the rest of the reset sequence");
                break;
            }
            let write = session.peripheral.write(
                &session.control_point,
                &bytes,
                WriteType::WithResponse,
            );
            match tokio::time::timeout(SAFETY_WRITE_TIMEOUT, write).await {
                Ok(Ok(())) => tracing::debug!(%op, raw = %hex(&bytes), "shutdown write"),
                Ok(Err(e)) => tracing::warn!(%op, error = %e, "shutdown write failed"),
                Err(_) => tracing::warn!(%op, "shutdown write timed out"),
            }
            // Respect the trainer's pacing even on the way out.
            tokio::time::sleep(Duration::from_millis(60)).await;
        }

        match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await {
            Ok(Ok(())) => tracing::info!("disconnected from the trainer"),
            Ok(Err(e)) => tracing::warn!(error = %e, "disconnect failed"),
            // The cost of this lands on the *next* run, not this one: a link we
            // could not close stays up in BlueZ after the process exits, and
            // the next connect inherits a half-dead session that reports some
            // frames and not others. Worth saying plainly rather than as a
            // bare timeout.
            Err(_) => tracing::warn!(
                "disconnect timed out — the link may be left open for the next run"
            ),
        }
        self.set_state(ConnectionState::Idle);
    }
}

/// FR-2.8: has enough time passed since the last control point write?
///
/// Split out from the actor so the rate limit is testable with explicit
/// timestamps rather than by watching a real clock.
fn write_allowed(last_write: Option<Instant>, now: Instant, min_interval: Duration) -> bool {
    match last_write {
        None => true,
        Some(last) => now.saturating_duration_since(last) >= min_interval,
    }
}

/// How long until [`write_allowed`] becomes true.
fn time_until_write_allowed(
    last_write: Option<Instant>,
    now: Instant,
    min_interval: Duration,
) -> Duration {
    match last_write {
        None => Duration::ZERO,
        Some(last) => min_interval.saturating_sub(now.saturating_duration_since(last)),
    }
}

enum Reconnected {
    Ok(mpsc::Receiver<ValueNotification>),
    Shutdown(Option<oneshot::Sender<()>>),
    GaveUp,
}

/// How one reconnect attempt ended: on its own, or because the caller stopped
/// waiting for it (FR-1.10).
enum Attempt {
    /// Boxed only to keep the two variants a similar size; a `ConnectedTrainer`
    /// carries the whole session.
    Finished(Box<Result<ConnectedTrainer, FtmsError>>),
    Abandoned(Option<oneshot::Sender<()>>),
}

fn complete(reply: Reply, result: Result<(), FtmsError>) {
    match reply {
        Reply::Target { sent, tx } => {
            let _ = tx.send(result.map(|()| ControlOutcome::Acknowledged { sent }));
        }
        Reply::Unit(tx) => {
            let _ = tx.send(result);
        }
    }
}

fn set_state(
    state_tx: &watch::Sender<ConnectionState>,
    events_tx: &broadcast::Sender<FtmsEvent>,
    state: ConnectionState,
) {
    if *state_tx.borrow() == state {
        return;
    }
    tracing::info!(?state, "connection state");
    let _ = state_tx.send(state.clone());
    let _ = events_tx.send(FtmsEvent::State(state));
}

async fn next_notification(
    rx: &mut Option<mpsc::Receiver<ValueNotification>>,
) -> Option<ValueNotification> {
    match rx {
        Some(rx) => rx.recv().await,
        // No stream: park forever and let the other select arms drive.
        None => std::future::pending().await,
    }
}

/// The SAF-2 sequence, as pure data so it can be unit-tested.
///
/// Order matters: zero the active targets *first*, so that even if `Reset` is
/// rejected the trainer is already at minimum load.
pub fn safety_reset_commands(
    limits: &SafetyLimits,
    caps: &TrainerCapabilities,
    use_simulation: bool,
    sim_template: SimulationParameters,
) -> Vec<(OpCode, Vec<u8>)> {
    let mut out = Vec::new();
    let feature = caps.feature;
    let supports = |f: fn(FitnessMachineFeature) -> bool| feature.map(f).unwrap_or(true);

    if use_simulation && supports(FitnessMachineFeature::supports_simulation) {
        out.push((
            OpCode::SetIndoorBikeSimulationParameters,
            cp::set_simulation_parameters(SimulationParameters {
                grade_percent: 0.0,
                wind_speed_mps: 0.0,
                ..sim_template
            }),
        ));
    }
    if supports(FitnessMachineFeature::supports_inclination_target) {
        out.push((
            OpCode::SetTargetInclination,
            cp::set_target_inclination(0.0),
        ));
    }
    if supports(FitnessMachineFeature::supports_resistance_target) {
        // The lowest level both the trainer and the safety limits permit.
        let trainer_min = caps.resistance_range.map(|r| r.min).unwrap_or(i16::MIN);
        let level = limits.min_resistance.max(trainer_min);
        out.push((
            OpCode::SetTargetResistanceLevel,
            cp::set_target_resistance(level),
        ));
    }
    out.push((OpCode::Reset, cp::reset()));
    out.push((
        OpCode::StopOrPause,
        cp::stop_or_pause(StopOrPause::Stop),
    ));
    out
}

// ---------------------------------------------------------------------------
// Connection sequence
// ---------------------------------------------------------------------------

async fn connect_session(
    adapter: &Adapter,
    selector: &TrainerSelector,
    config: &FtmsConfig,
    state_tx: &watch::Sender<ConnectionState>,
    in_flight: &InFlight,
) -> Result<ConnectedTrainer, FtmsError> {
    let _ = state_tx.send_if_modified(|s| {
        if *s == ConnectionState::Scanning {
            false
        } else {
            *s = ConnectionState::Scanning;
            true
        }
    });

    let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
    // From here until this function returns, cancelling the caller is the only
    // thing that can leave a link open with nobody to close it. Hand the
    // peripheral over now, before `connect()` — a cancellation lands wherever it
    // lands, including halfway through the connect itself (FR-1.10).
    in_flight.hold(peripheral.clone());
    let described = scan::describe(&peripheral).await;
    let address = described
        .as_ref()
        .map(|d| d.address.clone())
        .unwrap_or_else(|| peripheral.address().to_string().to_lowercase());
    let name = described.as_ref().and_then(|d| d.name.clone());

    let _ = state_tx.send(ConnectionState::Connecting);

    // Never adopt a link we did not open. A peripheral found already connected
    // usually means a previous run's shutdown ran out of budget before closing
    // it, and an inherited half-closed session reports some frames and not
    // others rather than failing outright (§7.1, SAF-9).
    if peripheral.is_connected().await.unwrap_or(false) {
        let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await;
    }
    peripheral.connect().await?;

    // From here on a failure leaves a live GATT link behind, and a peripheral
    // that accepts one connection at a time (A-3) would stay unavailable to the
    // next attempt. Set it up separately so every error path disconnects.
    match setup_session(peripheral.clone(), config, state_tx).await {
        Ok((session, capabilities, notifications)) => {
            in_flight.released();
            Ok(ConnectedTrainer {
                session,
                capabilities,
                notifications,
                address,
                name,
            })
        }
        Err(e) => {
            tracing::warn!(error = %e, "connection setup failed; disconnecting");
            let _ = peripheral.disconnect().await;
            in_flight.released();
            Err(e)
        }
    }
}

async fn setup_session(
    peripheral: Peripheral,
    config: &FtmsConfig,
    _state_tx: &watch::Sender<ConnectionState>,
) -> Result<(Session, TrainerCapabilities, mpsc::Receiver<ValueNotification>), FtmsError> {
    peripheral.discover_services().await?;

    let chars = peripheral.characteristics();
    let find = |uuid: Uuid| -> Option<Characteristic> {
        chars
            .iter()
            .find(|c| c.uuid == uuid && c.service_uuid == uuids::FITNESS_MACHINE_SERVICE)
            .or_else(|| chars.iter().find(|c| c.uuid == uuid))
            .cloned()
    };

    let indoor_bike_data = find(uuids::INDOOR_BIKE_DATA)
        .ok_or(FtmsError::MissingCharacteristic("Indoor Bike Data (0x2AD2)"))?;
    let control_point = find(uuids::FITNESS_MACHINE_CONTROL_POINT).ok_or(
        FtmsError::MissingCharacteristic("Fitness Machine Control Point (0x2AD9)"),
    )?;

    // Capabilities before control, so the very first target is already gated.
    let capabilities = read_capabilities(&peripheral, &find).await;
    tracing::info!(?capabilities, "trainer capabilities");

    // One forwarding task per connection turns the notification stream into a
    // channel, which keeps the actor's select loop free of borrow gymnastics.
    let mut stream = peripheral.notifications().await?;
    let (notif_tx, notif_rx) = mpsc::channel(256);
    tokio::spawn(async move {
        while let Some(n) = stream.next().await {
            if notif_tx.send(n).await.is_err() {
                break;
            }
        }
        tracing::debug!("notification stream ended");
    });

    peripheral.subscribe(&indoor_bike_data).await?;
    peripheral.subscribe(&control_point).await?;
    if let Some(status) = find(uuids::FITNESS_MACHINE_STATUS) {
        if let Err(e) = peripheral.subscribe(&status).await {
            tracing::debug!(error = %e, "could not subscribe to Fitness Machine Status");
        }
    }

    // The trainer's Zwift channel, which is where its cadence lives — FTMS on
    // this hardware reports none at all (see `zwift::RidingData`), and with the
    // speed taken from cadence × gear, no cadence means no ride.
    //
    // Optional in every sense: a trainer without the service simply rides
    // without cadence, and nothing here may cost us the FTMS session that is
    // already working. Hence the warnings rather than `?`.
    subscribe_zwift_channel(&peripheral, &chars).await;

    let mut notif_rx = notif_rx;

    // FR-2.1: control first, everything else after.
    handshake(
        &peripheral,
        &control_point,
        &mut notif_rx,
        OpCode::RequestControl,
        cp::request_control(),
        config.ack_timeout,
    )
    .await?;

    if config.start_on_connect {
        // A trainer that does not implement Start/Resume is not a problem.
        if let Err(e) = handshake(
            &peripheral,
            &control_point,
            &mut notif_rx,
            OpCode::StartOrResume,
            cp::start_or_resume(),
            config.ack_timeout,
        )
        .await
        {
            tracing::info!(error = %e, "StartOrResume was not accepted; continuing");
        }
    }

    Ok((
        Session {
            peripheral,
            control_point,
            indoor_bike_data,
        },
        capabilities,
        notif_rx,
    ))
}

/// Subscribe to the trainer's Zwift channel and complete the `RideOn`
/// handshake, so it starts reporting cadence.
///
/// The handshake is the part that is easy to miss. Subscribing alone is not
/// enough: like the Click, the trainer's Zwift service says nothing until it
/// has been greeted, so a client that only subscribes sits there receiving
/// silence and concludes the device has no cadence to give. Every symptom of
/// that is indistinguishable from a trainer that genuinely has none.
///
/// Entirely best-effort. A trainer without the service, or one that refuses the
/// write, rides without cadence; none of it may disturb the FTMS session, which
/// is the part that actually controls the resistance.
async fn subscribe_zwift_channel(
    peripheral: &Peripheral,
    chars: &std::collections::BTreeSet<Characteristic>,
) {
    let zwift_char = |uuid: Uuid| chars.iter().find(|c| c.uuid == uuid).cloned();

    let zwift_chars: Vec<&Characteristic> = chars
        .iter()
        .filter(|c| zwift::is_zwift_uuid(c.uuid))
        .collect();
    if zwift_chars.is_empty() {
        tracing::info!("trainer exposes no Zwift channel; it will report no cadence");
        return;
    }
    // Listed because which characteristic a *trainer* publishes riding data on
    // is not documented — the Click's is `ASYNC`, and betting on that being
    // universal is what this list exists to disprove or confirm.
    for c in &zwift_chars {
        tracing::debug!(uuid = %c.uuid, properties = ?c.properties, "zwift characteristic");
    }

    // Subscribe to every notifying characteristic in the service, exactly as
    // the Click path does, rather than guessing which one carries cadence. A
    // subscription we did not need costs nothing; the one we failed to make
    // costs the entire ride, because speed comes from cadence.
    let mut subscribed = 0;
    for c in &zwift_chars {
        if !c
            .properties
            .intersects(btleplug::api::CharPropFlags::NOTIFY | btleplug::api::CharPropFlags::INDICATE)
        {
            continue;
        }
        match peripheral.subscribe(c).await {
            Ok(()) => {
                subscribed += 1;
                tracing::debug!(uuid = %c.uuid, "subscribed to a Zwift characteristic");
            }
            Err(e) => tracing::warn!(uuid = %c.uuid, error = %e, "Zwift subscribe failed"),
        }
    }
    if subscribed == 0 {
        tracing::warn!("the trainer's Zwift service notifies on nothing; no cadence");
        return;
    }

    // Subscribed above, before writing, so the reply cannot outrun us.
    let Some(sync_rx) = zwift_char(zwift::SYNC_RX) else {
        // Subscribed but ungreetable. Worth saying: if cadence never arrives,
        // this line is the reason.
        tracing::warn!("trainer has a Zwift channel but no sync RX to greet it on");
        return;
    };
    let write_type = if sync_rx
        .properties
        .contains(btleplug::api::CharPropFlags::WRITE_WITHOUT_RESPONSE)
    {
        WriteType::WithoutResponse
    } else {
        WriteType::WithResponse
    };
    match peripheral
        .write(&sync_rx, &zwift::handshake(&zwift::REQUEST_START), write_type)
        .await
    {
        Ok(()) => tracing::info!("greeted the trainer's Zwift channel; expecting cadence"),
        Err(e) => tracing::warn!(error = %e, "Zwift handshake write failed; expect no cadence"),
    }
}

async fn read_capabilities(
    peripheral: &Peripheral,
    find: &impl Fn(Uuid) -> Option<Characteristic>,
) -> TrainerCapabilities {
    async fn read(peripheral: &Peripheral, ch: Option<Characteristic>) -> Option<Vec<u8>> {
        let ch = ch?;
        match peripheral.read(&ch).await {
            Ok(v) => {
                tracing::debug!(uuid = %ch.uuid, raw = %hex(&v), "read capability characteristic");
                Some(v)
            }
            Err(e) => {
                tracing::debug!(uuid = %ch.uuid, error = %e, "capability characteristic unreadable");
                None
            }
        }
    }

    let feature = read(peripheral, find(uuids::FITNESS_MACHINE_FEATURE))
        .await
        .and_then(|v| FitnessMachineFeature::decode(&v).ok());
    let resistance_range = read(peripheral, find(uuids::SUPPORTED_RESISTANCE_LEVEL_RANGE))
        .await
        .and_then(|v| ResistanceLevelRange::decode(&v).ok());
    let power_range = read(peripheral, find(uuids::SUPPORTED_POWER_RANGE))
        .await
        .and_then(|v| PowerRange::decode(&v).ok());
    let inclination_range = read(peripheral, find(uuids::SUPPORTED_INCLINATION_RANGE))
        .await
        .and_then(|v| InclinationRange::decode(&v).ok());

    TrainerCapabilities {
        feature,
        resistance_range,
        power_range,
        inclination_range,
    }
}

/// Write a procedure and wait for its indication, discarding anything else that
/// arrives meanwhile. Used only during the connection handshake, before the
/// actor loop exists.
async fn handshake(
    peripheral: &Peripheral,
    control_point: &Characteristic,
    notif_rx: &mut mpsc::Receiver<ValueNotification>,
    op: OpCode,
    bytes: Vec<u8>,
    timeout: Duration,
) -> Result<(), FtmsError> {
    tracing::debug!(%op, raw = %hex(&bytes), "handshake write");
    peripheral
        .write(control_point, &bytes, WriteType::WithResponse)
        .await?;

    let deadline = tokio::time::Instant::now() + timeout;
    loop {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
        if remaining.is_zero() {
            return Err(FtmsError::Unacknowledged {
                op,
                timeout_ms: timeout.as_millis() as u64,
            });
        }
        match tokio::time::timeout(remaining, notif_rx.recv()).await {
            Err(_) | Ok(None) => {
                return Err(FtmsError::Unacknowledged {
                    op,
                    timeout_ms: timeout.as_millis() as u64,
                })
            }
            Ok(Some(n)) => {
                if n.uuid != uuids::FITNESS_MACHINE_CONTROL_POINT {
                    continue;
                }
                let resp = cp::decode_response(&n.value)?;
                if resp.raw_request_op_code != op.as_u8() {
                    continue;
                }
                return if resp.result.is_success() {
                    Ok(())
                } else {
                    Err(FtmsError::Rejected {
                        op,
                        result: resp.result,
                    })
                };
            }
        }
    }
}

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

    #[test]
    fn backoff_grows_then_saturates() {
        let b = Backoff {
            initial: Duration::from_millis(500),
            max: Duration::from_secs(30),
            multiplier: 2.0,
            max_attempts: None,
        };
        assert_eq!(b.delay(0), Duration::from_millis(500));
        assert_eq!(b.delay(1), Duration::from_millis(1000));
        assert_eq!(b.delay(2), Duration::from_millis(2000));
        assert_eq!(b.delay(3), Duration::from_millis(4000));
        assert_eq!(b.delay(6), Duration::from_millis(32000).min(b.max));
        assert_eq!(b.delay(6), Duration::from_secs(30));
        // Never overflows, however many attempts.
        assert_eq!(b.delay(u32::MAX), Duration::from_secs(30));
    }

    #[test]
    fn backoff_is_monotonic() {
        let b = Backoff::default();
        let mut prev = Duration::ZERO;
        for attempt in 0..40 {
            let d = b.delay(attempt);
            assert!(d >= prev, "backoff went backwards at attempt {attempt}");
            assert!(d <= b.max);
            prev = d;
        }
    }

    #[test]
    fn backoff_attempt_limit() {
        let unlimited = Backoff::default();
        assert!(!unlimited.exhausted(1_000_000));

        let limited = Backoff {
            max_attempts: Some(3),
            ..Backoff::default()
        };
        assert!(!limited.exhausted(0));
        assert!(!limited.exhausted(2));
        assert!(limited.exhausted(3));
        assert!(limited.exhausted(4));
    }

    #[test]
    fn default_config_meets_the_four_hertz_rate_limit() {
        let c = FtmsConfig::default();
        assert!(
            c.min_write_interval >= Duration::from_millis(250),
            "FR-2.8 requires <= 4 Hz control writes"
        );
        // A-1 is unresolved, so we default to the op code the reference
        // implementation is known to work with.
        assert!(!c.use_simulation_mode);
    }

    // -- FR-2.8 rate limiting ---------------------------------------------

    #[test]
    fn the_first_write_is_always_allowed() {
        let now = Instant::now();
        assert!(write_allowed(None, now, Duration::from_millis(250)));
        assert_eq!(
            time_until_write_allowed(None, now, Duration::from_millis(250)),
            Duration::ZERO
        );
    }

    #[test]
    fn writes_are_held_to_four_hertz() {
        let interval = Duration::from_millis(250);
        let last = Instant::now();

        // Immediately after a write: blocked.
        assert!(!write_allowed(Some(last), last, interval));
        assert_eq!(time_until_write_allowed(Some(last), last, interval), interval);

        // 249 ms later: still blocked.
        let t = last + Duration::from_millis(249);
        assert!(!write_allowed(Some(last), t, interval));
        assert_eq!(
            time_until_write_allowed(Some(last), t, interval),
            Duration::from_millis(1)
        );

        // Exactly at the interval: allowed.
        let t = last + interval;
        assert!(write_allowed(Some(last), t, interval));
        assert_eq!(time_until_write_allowed(Some(last), t, interval), Duration::ZERO);

        // Well past it: allowed, and no negative-duration underflow.
        let t = last + Duration::from_secs(60);
        assert!(write_allowed(Some(last), t, interval));
        assert_eq!(time_until_write_allowed(Some(last), t, interval), Duration::ZERO);
    }

    #[test]
    fn a_clock_that_appears_to_go_backwards_does_not_panic() {
        let interval = Duration::from_millis(250);
        let now = Instant::now();
        let later = now + Duration::from_secs(5);
        // `now` is before `later`: saturating arithmetic, no underflow panic.
        assert!(!write_allowed(Some(later), now, interval));
        assert_eq!(time_until_write_allowed(Some(later), now, interval), interval);
    }

    /// Walk a simulated second of a 4 Hz limiter and count the writes.
    #[test]
    fn no_more_than_four_writes_per_second() {
        let interval = FtmsConfig::default().min_write_interval;
        let start = Instant::now();
        let mut last: Option<Instant> = None;
        let mut writes = 0;
        // The caller offers a new target every 10 ms for one second.
        for step in 0..100 {
            let now = start + Duration::from_millis(step * 10);
            if write_allowed(last, now, interval) {
                writes += 1;
                last = Some(now);
            }
        }
        assert_eq!(writes, 4, "FR-2.8 caps control writes at 4 Hz");
    }

    // -- reply plumbing ----------------------------------------------------

    #[test]
    fn an_acknowledged_target_reports_the_value_actually_sent() {
        let (tx, rx) = oneshot::channel();
        let sent = ControlTarget::Gradient { percent: 15.0 };
        complete(Reply::Target { sent, tx }, Ok(()));
        assert_eq!(
            rx.blocking_recv().unwrap().unwrap(),
            ControlOutcome::Acknowledged { sent }
        );
    }

    #[test]
    fn a_rejected_target_surfaces_the_result_code() {
        let (tx, rx) = oneshot::channel();
        complete(
            Reply::Target {
                sent: ControlTarget::Power { watts: 200 },
                tx,
            },
            Err(FtmsError::Rejected {
                op: OpCode::SetTargetPower,
                result: ResultCode::ControlNotPermitted,
            }),
        );
        assert!(matches!(
            rx.blocking_recv().unwrap(),
            Err(FtmsError::Rejected {
                op: OpCode::SetTargetPower,
                result: ResultCode::ControlNotPermitted
            })
        ));
    }

    #[test]
    fn dropping_the_caller_does_not_panic_the_actor() {
        let (tx, rx) = oneshot::channel::<Result<(), FtmsError>>();
        drop(rx);
        // Must be a no-op, not an unwrap on a closed channel.
        complete(Reply::Unit(tx), Ok(()));
    }

    #[test]
    fn procedures_encode_to_the_right_op_codes() {
        assert_eq!(
            Procedure::RequestControl.encode(),
            (OpCode::RequestControl, vec![0x00])
        );
        assert_eq!(Procedure::Reset.encode(), (OpCode::Reset, vec![0x01]));
        assert_eq!(Procedure::Start.encode(), (OpCode::StartOrResume, vec![0x07]));
        assert_eq!(
            Procedure::Stop.encode(),
            (OpCode::StopOrPause, vec![0x08, 0x01])
        );
        assert_eq!(
            Procedure::Pause.encode(),
            (OpCode::StopOrPause, vec![0x08, 0x02])
        );
    }

    // -- SAF-2 -------------------------------------------------------------

    #[test]
    fn safety_reset_zeroes_grade_and_minimises_resistance() {
        let caps = TrainerCapabilities {
            feature: Some(FitnessMachineFeature {
                machine: 0,
                target: target_feature::INCLINATION | target_feature::RESISTANCE,
            }),
            resistance_range: Some(ResistanceLevelRange {
                min: 0,
                max: 100,
                increment: 1,
            }),
            ..Default::default()
        };
        let cmds = safety_reset_commands(
            &SafetyLimits::default(),
            &caps,
            false,
            SimulationParameters::default(),
        );
        let ops: Vec<OpCode> = cmds.iter().map(|(op, _)| *op).collect();
        assert_eq!(
            ops,
            vec![
                OpCode::SetTargetInclination,
                OpCode::SetTargetResistanceLevel,
                OpCode::Reset,
                OpCode::StopOrPause,
            ]
        );
        // Inclination exactly zero.
        assert_eq!(cmds[0].1, vec![0x03, 0x00, 0x00]);
        // Resistance at the minimum.
        assert_eq!(cmds[1].1, vec![0x04, 0x00, 0x00]);
        // Targets are zeroed before Reset, not after.
        assert!(ops.iter().position(|o| *o == OpCode::Reset).unwrap() > 1);
    }

    #[test]
    fn safety_reset_uses_the_trainers_minimum_when_it_is_above_the_configured_one() {
        let caps = TrainerCapabilities {
            resistance_range: Some(ResistanceLevelRange {
                min: 5,
                max: 40,
                increment: 1,
            }),
            ..Default::default()
        };
        let limits = SafetyLimits {
            min_resistance: 0,
            ..SafetyLimits::default()
        };
        let cmds =
            safety_reset_commands(&limits, &caps, false, SimulationParameters::default());
        let resistance = cmds
            .iter()
            .find(|(op, _)| *op == OpCode::SetTargetResistanceLevel)
            .expect("resistance reset present");
        assert_eq!(resistance.1, vec![0x04, 0x05, 0x00], "must not go below the trainer's own minimum (SAF-3)");
    }

    #[test]
    fn safety_reset_uses_the_configured_minimum_when_it_is_the_higher_one() {
        let caps = TrainerCapabilities {
            resistance_range: Some(ResistanceLevelRange {
                min: -20,
                max: 100,
                increment: 1,
            }),
            ..Default::default()
        };
        let limits = SafetyLimits {
            min_resistance: 0,
            ..SafetyLimits::default()
        };
        let cmds =
            safety_reset_commands(&limits, &caps, false, SimulationParameters::default());
        let resistance = cmds
            .iter()
            .find(|(op, _)| *op == OpCode::SetTargetResistanceLevel)
            .unwrap();
        assert_eq!(resistance.1, vec![0x04, 0x00, 0x00]);
    }

    #[test]
    fn safety_reset_skips_unsupported_op_codes() {
        let caps = TrainerCapabilities {
            feature: Some(FitnessMachineFeature {
                machine: 0,
                target: target_feature::POWER,
            }),
            ..Default::default()
        };
        let cmds = safety_reset_commands(
            &SafetyLimits::default(),
            &caps,
            false,
            SimulationParameters::default(),
        );
        let ops: Vec<OpCode> = cmds.iter().map(|(op, _)| *op).collect();
        assert_eq!(ops, vec![OpCode::Reset, OpCode::StopOrPause]);
    }

    #[test]
    fn safety_reset_always_ends_with_reset_and_stop() {
        for use_sim in [false, true] {
            let cmds = safety_reset_commands(
                &SafetyLimits::default(),
                &TrainerCapabilities::default(),
                use_sim,
                SimulationParameters::default(),
            );
            let ops: Vec<OpCode> = cmds.iter().map(|(op, _)| *op).collect();
            assert_eq!(ops[ops.len() - 2], OpCode::Reset);
            assert_eq!(ops[ops.len() - 1], OpCode::StopOrPause);
        }
    }

    #[test]
    fn the_shutdown_sequence_cannot_outlast_the_apps_exit() {
        // SAF-8 / NFR-9. Per-write timeouts alone are not a bound: five of them
        // plus their pacing and the disconnect can still outlast the caller
        // holding the window open, and a caller that gives up waiting leaves the
        // trainer loaded. Hence a deadline across the whole sequence.
        assert!(SAFETY_WRITE_TIMEOUT < SAFETY_SEQUENCE_BUDGET);
        assert!(SAFETY_SEQUENCE_BUDGET + DISCONNECT_TIMEOUT < Duration::from_secs(8));
    }

    #[test]
    fn a_truncated_shutdown_sequence_has_still_zeroed_the_load() {
        // What the budget above may cut short is the tail. That is only
        // acceptable because the zeroing writes come first — the trainer is at
        // minimum load before `Reset` and `Stop` are even attempted, which is
        // the part SAF-2 exists for.
        for use_sim in [false, true] {
            let cmds = safety_reset_commands(
                &SafetyLimits::default(),
                &TrainerCapabilities::default(),
                use_sim,
                SimulationParameters::default(),
            );
            let ops: Vec<OpCode> = cmds.iter().map(|(op, _)| *op).collect();
            let first_zeroing = ops
                .iter()
                .position(|op| {
                    matches!(
                        op,
                        OpCode::SetTargetInclination
                            | OpCode::SetTargetResistanceLevel
                            | OpCode::SetIndoorBikeSimulationParameters
                    )
                })
                .expect("the sequence must zero something");
            let reset = ops
                .iter()
                .position(|op| *op == OpCode::Reset)
                .expect("the sequence must reset");
            assert!(first_zeroing < reset, "{ops:?}");
        }
    }

    #[test]
    fn safety_reset_in_simulation_mode_sends_zero_grade_and_zero_wind() {
        let caps = TrainerCapabilities {
            feature: Some(FitnessMachineFeature {
                machine: 0,
                target: target_feature::INDOOR_BIKE_SIMULATION,
            }),
            ..Default::default()
        };
        let cmds = safety_reset_commands(
            &SafetyLimits::default(),
            &caps,
            true,
            SimulationParameters {
                wind_speed_mps: 9.0,
                grade_percent: 12.0,
                crr: 0.004,
                wind_resistance_coefficient: 0.51,
            },
        );
        let (op, bytes) = &cmds[0];
        assert_eq!(*op, OpCode::SetIndoorBikeSimulationParameters);
        // wind = 0, grade = 0
        assert_eq!(&bytes[1..5], &[0x00, 0x00, 0x00, 0x00]);
    }
}
