//! Zwift's proprietary BLE protocol — UUIDs, framing and message decoding
//! (REQUIREMENTS.md §2.3.1).
//!
//! This is the protocol the Zwift Click v2 speaks, and — per `probe inspect` —
//! the same custom service the Van Rysel D100 advertises alongside FTMS. It is
//! not a Bluetooth SIG standard: everything here comes from the public
//! documentation of three independent open-source implementations (§3.4–3.6),
//! **not** from copied code.
//!
//! # Confidence
//!
//! The UUIDs and the `RideOn` handshake prefix are corroborated by all three
//! references and are safe to rely on. Everything marked *unverified* below is
//! a hypothesis for `probe zwift` to test against the hardware — the tool
//! prints raw bytes precisely so these constants can be confirmed or replaced.
//!
//! Encryption (ECDH P-256 → HKDF → AES-256-CCM) is deliberately absent. A-3
//! records that at least one implementation talks to a Click *without* it and
//! still receives button and battery events; TASK-0 is to find out whether that
//! holds for a v2, because it would remove the crypto layer entirely.

use uuid::Uuid;

// ---------------------------------------------------------------------------
// UUIDs
// ---------------------------------------------------------------------------

/// Tail of Zwift's custom UUID space, `0000000x-19CA-4651-86E5-FA29DCDD09D1`.
const ZWIFT_D2: u16 = 0x19CA;
const ZWIFT_D3: u16 = 0x4651;
const ZWIFT_D4: [u8; 8] = [0x86, 0xE5, 0xFA, 0x29, 0xDC, 0xDD, 0x09, 0xD1];

/// Expand a Zwift assigned number onto Zwift's custom UUID base.
pub const fn zwift_uuid(assigned: u32) -> Uuid {
    Uuid::from_fields(assigned, ZWIFT_D2, ZWIFT_D3, &ZWIFT_D4)
}

/// Zwift's custom service, `00000001-…`.
///
/// **Confirmed on hardware (2026-08-05):** the Van Rysel D100 advertises this,
/// with service data `01`. The Click v2 does **not** — see [`SERVICE_FC82`].
pub const SERVICE: Uuid = zwift_uuid(0x0000_0001);

/// Zwift's SIG-allocated 16-bit service, `0xFC82`.
///
/// **Confirmed on hardware (2026-08-05):** this is the *only* vendor service a
/// Click v2 exposes. It advertises `0xFC82` with service data `00`, and after
/// connecting exposes Generic Access, Generic Attribute, Device Information,
/// Battery and `0xFC82` — and nothing in the `…-19CA-…` space at all.
///
/// This is why the trainer's Zwift characteristics are not a route to the
/// Click: they are two different services. Whether the *framing* inside
/// `0xFC82` is the same `RideOn` protocol is the open question, and the reason
/// `probe zwift` treats either service as a candidate.
pub const SERVICE_FC82: Uuid = crate::uuids::uuid16(0xFC82);

/// Every service known to carry Zwift's protocol, newest first. A device is
/// expected to expose exactly one.
pub const SERVICES: [Uuid; 2] = [SERVICE_FC82, SERVICE];

/// Async characteristic, `00000002-…` — notify. Unsolicited device events:
/// button state, battery, keepalives.
pub const ASYNC: Uuid = zwift_uuid(0x0000_0002);

/// Sync RX, `00000003-…` — write. Commands *to* the device, including the
/// `RideOn` handshake.
pub const SYNC_RX: Uuid = zwift_uuid(0x0000_0003);

/// Sync TX, `00000004-…` — indicate. Responses to whatever was written to
/// [`SYNC_RX`].
pub const SYNC_TX: Uuid = zwift_uuid(0x0000_0004);

/// `00000006-…` — indicate/read/write, purpose undetermined (§2.3.1). Present
/// on some devices; `probe zwift` subscribes to it just to see if it ever
/// speaks.
pub const UNKNOWN_6: Uuid = zwift_uuid(0x0000_0006);

/// Zwift's Bluetooth SIG manufacturer ID (2378).
pub const MANUFACTURER_ID: u16 = 0x094A;

/// Human-readable name for a Zwift UUID, for logging and the probe CLI.
pub fn well_known_name(uuid: Uuid) -> Option<&'static str> {
    if uuid == SERVICE_FC82 {
        return Some("Zwift service 0xFC82");
    }
    Some(match uuid {
        SERVICE => "Zwift custom service",
        ASYNC => "Zwift async (notify)",
        SYNC_RX => "Zwift sync RX (write)",
        SYNC_TX => "Zwift sync TX (indicate)",
        UNKNOWN_6 => "Zwift 0006 (purpose unknown)",
        _ => return None,
    })
}

/// True when `uuid` sits in Zwift's custom UUID space.
pub fn is_zwift_uuid(uuid: Uuid) -> bool {
    let (_, d2, d3, d4) = uuid.as_fields();
    d2 == ZWIFT_D2 && d3 == ZWIFT_D3 && *d4 == ZWIFT_D4
}

// ---------------------------------------------------------------------------
// Device discrimination
// ---------------------------------------------------------------------------

/// Which of a pair of pods this is.
///
/// A Click v2 is **two independent peripherals** (§2.3.1, FR-1.4), each with
/// its own address, battery and link — so the app has to track two of
/// everything, and the rider has to be told which of the two is missing.
///
/// They are named for the **shift paddle** each one carries rather than for the
/// side of the bar they clamp to. Left and right would be a guess: nothing in
/// the advertisement says which end of the handlebar a pod is on, and a rider
/// who mounts them the other way round makes the label a lie. The paddle is
/// printed on the pod, so `+` and `−` are checkable by eye and by pressing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PodId {
    /// The pod carrying the `−` paddle: shift down, and the D-pad.
    Minus,
    /// The pod carrying the `+` paddle: shift up, and the lettered face buttons.
    Plus,
}

impl PodId {
    pub const BOTH: [PodId; 2] = [PodId::Minus, PodId::Plus];

    pub fn other(self) -> Self {
        match self {
            PodId::Minus => PodId::Plus,
            PodId::Plus => PodId::Minus,
        }
    }

    /// Lowercase, stable — used as a key by the app and the webview.
    pub fn as_str(self) -> &'static str {
        match self {
            PodId::Minus => "minus",
            PodId::Plus => "plus",
        }
    }

    /// How the pod is written on screen.
    pub fn symbol(self) -> &'static str {
        match self {
            PodId::Minus => "−",
            PodId::Plus => "+",
        }
    }

    /// The paddle whose press proves a pod is the one we filed it under.
    pub fn paddle(self) -> Button {
        match self {
            PodId::Minus => Button::Minus,
            PodId::Plus => Button::Plus,
        }
    }
}

/// What kind of Zwift peripheral is advertising, from the first byte of its
/// manufacturer data (§2.3.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
    /// `0x09` — Click v1. Unencrypted; the easy case.
    ClickV1,
    /// `0x0B` / `0x0A` — Click v2. The target hardware (§2.3). **One byte per
    /// pod, not a version marker** — confirmed on our own pair, where
    /// `f4:c4:59:03:a1:8e` advertises `0x0B` and `c0:4a:0e:f9:a8:78` `0x0A`.
    ClickV2(PodId),
    /// `0x03` — Play, left-hand pod (`−` paddle). *Unverified.*
    PlayLeft,
    /// `0x02` — Play, right-hand pod (`+` paddle). *Unverified.*
    PlayRight,
    /// A Zwift device we do not have a byte for. Carries the raw value so the
    /// probe can report it rather than swallow it.
    Unknown(u8),
}

impl DeviceKind {
    /// Classify from the device type byte.
    ///
    /// Which v2 byte is which pod is a **starting guess**: §2.3.1 confirms one
    /// byte per pod, but no capture names them. It is read by analogy with
    /// Play, whose `+` pod is the lower byte (`0x02`) and `−` pod the higher
    /// (`0x03`), and it agrees with the one thing we did observe — the D-pad
    /// frames, which live beside the `−` paddle, came from the `0x0B` pod.
    ///
    /// The guess does not have to be right. The app confirms each pod the first
    /// time a paddle is pressed on it, and swaps the pair if the pods answer to
    /// the other name.
    pub fn from_type_byte(b: u8) -> Self {
        match b {
            0x09 => DeviceKind::ClickV1,
            0x0B => DeviceKind::ClickV2(PodId::Minus),
            0x0A => DeviceKind::ClickV2(PodId::Plus),
            0x03 => DeviceKind::PlayLeft,
            0x02 => DeviceKind::PlayRight,
            other => DeviceKind::Unknown(other),
        }
    }

    /// Classify from a peripheral's Zwift manufacturer data. The device type is
    /// the first byte; an empty payload tells us nothing.
    pub fn from_manufacturer_data(data: &[u8]) -> Option<Self> {
        data.first().copied().map(Self::from_type_byte)
    }

    /// True for a Click of either generation — the devices this app drives.
    pub fn is_click(self) -> bool {
        matches!(self, DeviceKind::ClickV1 | DeviceKind::ClickV2(_))
    }

    /// Which pod of a pair this is, where the advertisement says so.
    ///
    /// `None` for a v1 Click, which is a single unit, and for anything we
    /// cannot place — a pod with no id is still connectable, it just cannot be
    /// filed under one of the two slots on the connection screen.
    pub fn pod_id(self) -> Option<PodId> {
        match self {
            DeviceKind::ClickV2(side) => Some(side),
            DeviceKind::PlayLeft => Some(PodId::Minus),
            DeviceKind::PlayRight => Some(PodId::Plus),
            DeviceKind::ClickV1 | DeviceKind::Unknown(_) => None,
        }
    }

    pub fn describe(self) -> String {
        match self {
            DeviceKind::ClickV1 => "Zwift Click v1".into(),
            DeviceKind::ClickV2(PodId::Minus) => "Zwift Click v2 (− pod)".into(),
            DeviceKind::ClickV2(PodId::Plus) => "Zwift Click v2 (+ pod)".into(),
            DeviceKind::PlayLeft => "Zwift Play (left)".into(),
            DeviceKind::PlayRight => "Zwift Play (right)".into(),
            DeviceKind::Unknown(b) => format!("unrecognised Zwift device (type byte 0x{b:02x})"),
        }
    }
}

// ---------------------------------------------------------------------------
// Handshake
// ---------------------------------------------------------------------------

/// The `RideOn` magic that opens every Zwift session — ASCII, no terminator.
/// Corroborated by all three references.
pub const RIDE_ON: [u8; 6] = *b"RideOn";

/// The two bytes that follow `RideOn` on the way *in*.
///
/// **Confirmed on a Click v2 (2026-08-05):** writing `526964654f6e0009` to
/// [`SYNC_RX`] draws an immediate reply on [`SYNC_TX`], with no key exchange
/// and no encryption. This is the answer to TASK-0.
pub const REQUEST_START: [u8; 2] = [0x00, 0x09];

/// The two bytes that follow `RideOn` in the device's reply.
///
/// **Confirmed on a Click v2 (2026-08-05):** the pod answers `526964654f6e0203`
/// — `02 03`, not the `01 03` the public write-ups describe. Replies are
/// therefore matched on the `RideOn` prefix ([`is_ride_on_reply`]) rather than
/// on these two bytes, which evidently vary.
pub const RESPONSE_START: [u8; 2] = [0x02, 0x03];

/// Build a handshake frame: `RideOn` followed by `suffix`.
///
/// `suffix` is a parameter rather than a constant because which two bytes the
/// v2 wants is exactly what is unknown. A Play-style device additionally
/// appends a P-256 public key here; a Click is documented as not needing one on
/// the unencrypted path (A-3).
pub fn handshake(suffix: &[u8]) -> Vec<u8> {
    let mut frame = Vec::with_capacity(RIDE_ON.len() + suffix.len());
    frame.extend_from_slice(&RIDE_ON);
    frame.extend_from_slice(suffix);
    frame
}

/// Handshake suffixes to try, in order. The first is the confirmed one and
/// answers on the first attempt; the rest remain as fallbacks for firmware we
/// have not met.
pub const HANDSHAKE_CANDIDATES: &[(&str, &[u8])] = &[
    ("RideOn + 00 09 (confirmed on Click v2)", &REQUEST_START),
    ("RideOn + 01 02", &[0x01, 0x02]),
    ("RideOn alone (no suffix)", &[]),
];

/// True when `frame` is a `RideOn` reply from the device.
pub fn is_ride_on_reply(frame: &[u8]) -> bool {
    frame.starts_with(&RIDE_ON)
}

// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------

/// The leading byte of an async frame (§2.3.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageType {
    /// `0x07` — controller notification (Play-style analogue/button payload).
    ControllerNotification,
    /// `0x15` — empty frame / keepalive.
    KeepAlive,
    /// `0x19` — battery level. **Confirmed:** `191064` decodes to 100%.
    Battery,
    /// `0x23` — Click v2 button state: one varint holding a 32-bit active-low
    /// bitmask. **Confirmed on hardware (2026-08-05)**; this, not `0x37`, is
    /// what a v2 pod actually sends.
    ButtonBitmask,
    /// `0x37` — Click button state as two protobuf varints. Documented in the
    /// public write-ups (§2.3.1) but **never observed on our v2**, which uses
    /// [`MessageType::ButtonBitmask`]. Presumably v1.
    ClickButtons,
    /// Anything else. Kept rather than rejected — an unknown frame from a
    /// half-documented protocol is data, not an error.
    Unknown(u8),
}

impl MessageType {
    pub fn from_byte(b: u8) -> Self {
        match b {
            0x07 => MessageType::ControllerNotification,
            0x15 => MessageType::KeepAlive,
            0x19 => MessageType::Battery,
            0x23 => MessageType::ButtonBitmask,
            0x37 => MessageType::ClickButtons,
            other => MessageType::Unknown(other),
        }
    }

    pub fn describe(self) -> String {
        match self {
            MessageType::ControllerNotification => "controller notification (0x07)".into(),
            MessageType::KeepAlive => "keepalive / empty (0x15)".into(),
            MessageType::Battery => "battery (0x19)".into(),
            MessageType::ButtonBitmask => "Click v2 button bitmask (0x23)".into(),
            MessageType::ClickButtons => "Click button state (0x37)".into(),
            MessageType::Unknown(b) => format!("unknown (0x{b:02x})"),
        }
    }
}

/// An async frame split into its type byte and payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame<'a> {
    pub kind: MessageType,
    pub payload: &'a [u8],
}

/// Split a raw notification into type byte and payload. Returns `None` for an
/// empty frame, which carries no type at all.
pub fn parse_frame(raw: &[u8]) -> Option<Frame<'_>> {
    let (&first, rest) = raw.split_first()?;
    Some(Frame {
        kind: MessageType::from_byte(first),
        payload: rest,
    })
}

/// Button state from a `0x37` frame.
///
/// The payload is a two-field protobuf message. Zwift encodes **0 = pressed,
/// 1 = released** — inverted from the obvious reading, which is the kind of
/// detail that has to be confirmed against the hardware before it is trusted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClickButtons {
    /// Field 1 — the up / plus paddle.
    pub up_pressed: bool,
    /// Field 2 — the down / minus paddle.
    pub down_pressed: bool,
}

/// Decode a `0x37` payload. Absent fields default to released, because protobuf
/// omits fields at their default value and the wire cannot distinguish
/// "unchanged" from "not sent".
pub fn decode_click_buttons(payload: &[u8]) -> Result<ClickButtons, ProtobufError> {
    let fields = decode_varint_fields(payload)?;
    let value_of = |field: u32| fields.iter().find(|(f, _)| *f == field).map(|(_, v)| *v);
    Ok(ClickButtons {
        up_pressed: value_of(1) == Some(0),
        down_pressed: value_of(2) == Some(0),
    })
}

/// A physical button on the Click v2, and the bit it owns in the `0x23` mask.
///
/// **Mapped on hardware (2026-08-05)** by pressing each button in a known order
/// and correlating. The layout is orderly once seen: the D-pad takes bits 0–3,
/// the four face buttons take 4–7, and the two paddles sit further up at 8 and
/// 12.
///
/// `A` and `Z` initially disagreed between two captures and were settled by a
/// dedicated two-button test: `A` → bit 4, `Z` → bit 7, reproduced twice. The
/// first capture had simply been pressed out of order.
///
/// Bits 9, 10 and 11 belong to nothing we have found, so the paddles are not
/// contiguous with the rest.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Button {
    Left,
    Up,
    Right,
    Down,
    A,
    B,
    Y,
    Z,
    /// The `−` paddle.
    Minus,
    /// The `+` paddle. Note the gap: bits 9–11 belong to nothing we have found.
    Plus,
}

impl Button {
    /// Every button, in bit order.
    pub const ALL: [Button; 10] = [
        Button::Left,
        Button::Up,
        Button::Right,
        Button::Down,
        Button::A,
        Button::B,
        Button::Y,
        Button::Z,
        Button::Minus,
        Button::Plus,
    ];

    /// The bit this button clears when held.
    pub fn bit(self) -> u8 {
        match self {
            Button::Left => 0,
            Button::Up => 1,
            Button::Right => 2,
            Button::Down => 3,
            Button::A => 4,
            Button::B => 5,
            Button::Y => 6,
            Button::Z => 7,
            Button::Minus => 8,
            Button::Plus => 12,
        }
    }

    /// The button owning `bit`, or `None` for the bits nothing claims.
    pub fn from_bit(bit: u8) -> Option<Self> {
        Self::ALL.into_iter().find(|b| b.bit() == bit)
    }

    pub fn label(self) -> &'static str {
        match self {
            Button::Left => "left",
            Button::Up => "up",
            Button::Right => "right",
            Button::Down => "down",
            Button::A => "A",
            Button::B => "B",
            Button::Y => "Y",
            Button::Z => "Z",
            Button::Minus => "-",
            Button::Plus => "+",
        }
    }
}

/// The button state a Click v2 sends in a `0x23` frame.
///
/// One varint field carrying a 32-bit mask in which **a clear bit means
/// pressed**. Idle is `0xFFFFFFFF`; the pod streams this at roughly 10 Hz
/// whenever anything is held, and sends the all-ones frame on release.
///
/// Observed bits so far: 0, 1, 3, 8 and 12, including 0 and 1 clear in the same
/// frame — so the mask is genuinely simultaneous state, not an event code.
/// **Which physical button each bit belongs to is not yet mapped**, which is
/// why this type exposes the raw mask rather than named buttons it cannot
/// honestly fill in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ButtonBitmask {
    /// The mask exactly as received, active-low.
    pub raw: u32,
}

impl ButtonBitmask {
    /// True when the button on `bit` is held.
    pub fn is_pressed(self, bit: u8) -> bool {
        bit < 32 && self.raw & (1u32 << bit) == 0
    }

    /// Every held button, lowest bit first.
    pub fn pressed_bits(self) -> Vec<u8> {
        (0..32).filter(|b| self.is_pressed(*b)).collect()
    }

    /// True when nothing at all is held.
    pub fn is_idle(self) -> bool {
        self.raw == u32::MAX
    }

    /// True when `button` is held.
    pub fn holds(self, button: Button) -> bool {
        self.is_pressed(button.bit())
    }

    /// Every held button we have a name for, in bit order. A clear bit with no
    /// known button is reported by [`Self::pressed_bits`] but not here.
    pub fn pressed_buttons(self) -> Vec<Button> {
        Button::ALL
            .into_iter()
            .filter(|b| self.holds(*b))
            .collect()
    }
}

/// A press or release, derived from consecutive [`ButtonBitmask`] frames.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ButtonEvent {
    pub button: Button,
    /// True on the press edge, false on the release edge.
    pub pressed: bool,
}

/// Turns the pod's ~10 Hz state stream into press and release *edges*.
///
/// The pod repeats the same mask for as long as a button is held, so a consumer
/// that acted on every frame would shift ten gears per second. This holds the
/// previous mask and reports only what changed.
///
/// A disconnect must call [`ButtonTracker::reset`]: without it, a button held
/// as the link drops would never produce its release edge, and the next
/// connection would open with a phantom press still latched.
#[derive(Debug, Clone, Copy, Default)]
pub struct ButtonTracker {
    previous: Option<ButtonBitmask>,
}

impl ButtonTracker {
    pub fn new() -> Self {
        Self::default()
    }

    /// Feed the next mask; get back the edges it implies, in bit order.
    ///
    /// The very first mask after construction or [`reset`](Self::reset) reports
    /// any already-held button as a press, which is what a rider holding a
    /// paddle through a reconnect would expect.
    pub fn update(&mut self, mask: ButtonBitmask) -> Vec<ButtonEvent> {
        let was = self.previous.unwrap_or(ButtonBitmask { raw: u32::MAX });
        self.previous = Some(mask);

        Button::ALL
            .into_iter()
            .filter_map(|button| {
                let now = mask.holds(button);
                (now != was.holds(button)).then_some(ButtonEvent {
                    button,
                    pressed: now,
                })
            })
            .collect()
    }

    /// Forget the last mask. Call on disconnect so a held button cannot latch
    /// across the gap.
    pub fn reset(&mut self) {
        self.previous = None;
    }

    /// Buttons currently held, as far as this tracker has seen.
    pub fn held(&self) -> Vec<Button> {
        self.previous.map(|m| m.pressed_buttons()).unwrap_or_default()
    }
}

/// Decode a `0x23` payload into the active-low button mask.
///
/// The varint is wider than 32 bits on the wire (protobuf sign-extends), so the
/// value is truncated to the low 32 bits — the upper bits are sign extension,
/// not buttons.
pub fn decode_button_bitmask(payload: &[u8]) -> Result<ButtonBitmask, ProtobufError> {
    let fields = decode_varint_fields(payload)?;
    let raw = fields
        .iter()
        .find(|(f, _)| *f == 1)
        .map(|(_, v)| *v as u32)
        .unwrap_or(u32::MAX);
    Ok(ButtonBitmask { raw })
}

/// Battery percentage from a `0x19` frame — a single varint field.
pub fn decode_battery(payload: &[u8]) -> Result<Option<u8>, ProtobufError> {
    let fields = decode_varint_fields(payload)?;
    Ok(fields.first().map(|(_, v)| (*v).min(255) as u8))
}

// ---------------------------------------------------------------------------
// Minimal protobuf
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ProtobufError {
    #[error("truncated varint at byte {offset}")]
    TruncatedVarint { offset: usize },
    #[error("varint at byte {offset} is longer than 10 bytes")]
    OverlongVarint { offset: usize },
    #[error("wire type {wire_type} at byte {offset} is not a varint")]
    UnsupportedWireType { wire_type: u8, offset: usize },
}

/// Decode a protobuf message consisting entirely of varint fields, returning
/// `(field number, value)` pairs in wire order.
///
/// Deliberately not a protobuf library: the two messages this app cares about
/// are a handful of varints each, and pulling in `prost` plus a `.proto` we
/// would have to reverse-engineer anyway buys nothing. A non-varint wire type
/// is an error rather than something to skip — it would mean the message is not
/// the shape we think it is, and silently returning a partial decode is how a
/// wrong assumption survives.
pub fn decode_varint_fields(mut data: &[u8]) -> Result<Vec<(u32, u64)>, ProtobufError> {
    let mut out = Vec::new();
    let mut offset = 0;
    while !data.is_empty() {
        let (tag, consumed) = decode_varint(data, offset)?;
        data = &data[consumed..];
        offset += consumed;

        let wire_type = (tag & 0x07) as u8;
        if wire_type != 0 {
            return Err(ProtobufError::UnsupportedWireType {
                wire_type,
                offset: offset - consumed,
            });
        }
        let field = (tag >> 3) as u32;

        let (value, consumed) = decode_varint(data, offset)?;
        data = &data[consumed..];
        offset += consumed;

        out.push((field, value));
    }
    Ok(out)
}

/// Decode one base-128 varint. `offset` is only used for error reporting.
fn decode_varint(data: &[u8], offset: usize) -> Result<(u64, usize), ProtobufError> {
    let mut value: u64 = 0;
    for (i, &b) in data.iter().enumerate() {
        if i >= 10 {
            return Err(ProtobufError::OverlongVarint { offset });
        }
        value |= u64::from(b & 0x7f) << (7 * i);
        if b & 0x80 == 0 {
            return Ok((value, i + 1));
        }
    }
    Err(ProtobufError::TruncatedVarint { offset })
}

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

    #[test]
    fn service_uuid_matches_the_documented_value() {
        assert_eq!(
            SERVICE.to_string(),
            "00000001-19ca-4651-86e5-fa29dcdd09d1"
        );
        assert_eq!(ASYNC.to_string(), "00000002-19ca-4651-86e5-fa29dcdd09d1");
        assert_eq!(SYNC_RX.to_string(), "00000003-19ca-4651-86e5-fa29dcdd09d1");
        assert_eq!(SYNC_TX.to_string(), "00000004-19ca-4651-86e5-fa29dcdd09d1");
        assert_eq!(
            UNKNOWN_6.to_string(),
            "00000006-19ca-4651-86e5-fa29dcdd09d1"
        );
    }

    #[test]
    fn the_click_v2_service_is_the_sig_short_uuid() {
        assert_eq!(
            SERVICE_FC82.to_string(),
            "0000fc82-0000-1000-8000-00805f9b34fb"
        );
        // It is a SIG-base UUID, so it is deliberately *not* in Zwift's custom
        // space — that difference is the whole finding.
        assert!(!is_zwift_uuid(SERVICE_FC82));
        assert_eq!(crate::uuids::short_id(SERVICE_FC82), Some(0xFC82));
        assert_eq!(well_known_name(SERVICE_FC82), Some("Zwift service 0xFC82"));
        assert!(SERVICES.contains(&SERVICE_FC82) && SERVICES.contains(&SERVICE));
    }

    #[test]
    fn zwift_uuids_are_recognised_and_ftms_ones_are_not() {
        assert!(is_zwift_uuid(SERVICE));
        assert!(is_zwift_uuid(SYNC_TX));
        assert!(!is_zwift_uuid(crate::uuids::FITNESS_MACHINE_SERVICE));
        assert!(!is_zwift_uuid(crate::uuids::INDOOR_BIKE_DATA));
    }

    #[test]
    fn characteristics_have_names() {
        assert_eq!(well_known_name(SYNC_RX), Some("Zwift sync RX (write)"));
        assert_eq!(well_known_name(crate::uuids::INDOOR_BIKE_DATA), None);
    }

    #[test]
    fn device_type_bytes_classify() {
        assert_eq!(DeviceKind::from_type_byte(0x09), DeviceKind::ClickV1);
        // §2.3.1: one byte per pod, not a version marker. The two v2 bytes must
        // therefore land on *different* pods — a mapping that collapsed them
        // would put both pods in one slot and lose the other entirely.
        assert_eq!(
            DeviceKind::from_type_byte(0x0B),
            DeviceKind::ClickV2(PodId::Minus)
        );
        assert_eq!(
            DeviceKind::from_type_byte(0x0A),
            DeviceKind::ClickV2(PodId::Plus)
        );
        assert_eq!(DeviceKind::from_type_byte(0x02), DeviceKind::PlayRight);
        assert_eq!(DeviceKind::from_type_byte(0xFE), DeviceKind::Unknown(0xFE));
        assert!(DeviceKind::from_type_byte(0x0B).is_click());
        assert!(!DeviceKind::from_type_byte(0x03).is_click());
    }

    #[test]
    fn manufacturer_data_needs_at_least_one_byte() {
        assert_eq!(
            DeviceKind::from_manufacturer_data(&[0x0A, 0x00]),
            Some(DeviceKind::ClickV2(PodId::Plus))
        );
        assert_eq!(DeviceKind::from_manufacturer_data(&[]), None);
    }

    #[test]
    fn a_pair_of_pods_covers_both_ids_and_nothing_else_claims_one() {
        let minus = DeviceKind::from_type_byte(0x0B).pod_id();
        let plus = DeviceKind::from_type_byte(0x0A).pod_id();
        assert_eq!(minus, Some(PodId::Minus));
        assert_eq!(plus, Some(PodId::Plus));
        assert_eq!(minus.map(PodId::other), plus);

        // A v1 Click is a single unit: giving it an id would fill a slot the
        // rider has no pod for.
        assert_eq!(DeviceKind::ClickV1.pod_id(), None);
        assert_eq!(DeviceKind::Unknown(0x77).pod_id(), None);
    }

    #[test]
    fn pod_names_are_the_keys_the_webview_switches_on() {
        assert_eq!(PodId::Minus.as_str(), "minus");
        assert_eq!(PodId::Plus.as_str(), "plus");
        assert_eq!(PodId::BOTH, [PodId::Minus, PodId::Plus]);
    }

    #[test]
    fn a_pod_is_named_for_the_paddle_that_proves_which_one_it_is() {
        // The whole point of naming them `+` and `−` rather than left and
        // right: pressing the paddle settles the question, and no guess about
        // how the pods are mounted can make the label wrong.
        assert_eq!(PodId::Plus.paddle(), Button::Plus);
        assert_eq!(PodId::Minus.paddle(), Button::Minus);
        assert_eq!(PodId::Plus.symbol(), "+");
    }

    #[test]
    fn handshake_is_ride_on_plus_suffix() {
        assert_eq!(
            handshake(&REQUEST_START),
            vec![0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e, 0x00, 0x09]
        );
        assert_eq!(handshake(&[]), b"RideOn".to_vec());
        // Every candidate is a well-formed frame we could actually write.
        for (_, suffix) in HANDSHAKE_CANDIDATES {
            assert!(handshake(suffix).starts_with(b"RideOn"));
        }
    }

    #[test]
    fn ride_on_replies_are_recognised_by_prefix() {
        assert!(is_ride_on_reply(b"RideOn\x01\x03"));
        assert!(!is_ride_on_reply(b"\x37\x08\x01"));
        assert!(!is_ride_on_reply(b""));
    }

    #[test]
    fn frames_split_into_type_and_payload() {
        let f = parse_frame(&[0x37, 0x08, 0x01]).unwrap();
        assert_eq!(f.kind, MessageType::ClickButtons);
        assert_eq!(f.payload, &[0x08, 0x01]);

        assert_eq!(parse_frame(&[0x15]).unwrap().kind, MessageType::KeepAlive);
        assert_eq!(
            parse_frame(&[0xAB]).unwrap().kind,
            MessageType::Unknown(0xAB)
        );
        assert!(parse_frame(&[]).is_none());
    }

    #[test]
    fn varints_decode() {
        // field 1, varint, value 1 -> tag 0x08, value 0x01
        assert_eq!(decode_varint_fields(&[0x08, 0x01]).unwrap(), vec![(1, 1)]);
        // two fields
        assert_eq!(
            decode_varint_fields(&[0x08, 0x00, 0x10, 0x01]).unwrap(),
            vec![(1, 0), (2, 1)]
        );
        // multi-byte value: 300 = 0xAC 0x02
        assert_eq!(
            decode_varint_fields(&[0x08, 0xAC, 0x02]).unwrap(),
            vec![(1, 300)]
        );
        assert_eq!(decode_varint_fields(&[]).unwrap(), vec![]);
    }

    #[test]
    fn malformed_protobuf_is_an_error_not_a_partial_decode() {
        // Tag present, value missing.
        assert!(matches!(
            decode_varint_fields(&[0x08]),
            Err(ProtobufError::TruncatedVarint { .. })
        ));
        // Continuation bit set on the last byte.
        assert!(matches!(
            decode_varint_fields(&[0x08, 0x80]),
            Err(ProtobufError::TruncatedVarint { .. })
        ));
        // Wire type 2 (length-delimited) is not something we handle.
        assert!(matches!(
            decode_varint_fields(&[0x0A, 0x01, 0x00]),
            Err(ProtobufError::UnsupportedWireType { wire_type: 2, .. })
        ));
    }

    #[test]
    fn click_buttons_decode_with_zero_meaning_pressed() {
        let both_released = decode_click_buttons(&[0x08, 0x01, 0x10, 0x01]).unwrap();
        assert!(!both_released.up_pressed && !both_released.down_pressed);

        let up = decode_click_buttons(&[0x08, 0x00, 0x10, 0x01]).unwrap();
        assert!(up.up_pressed && !up.down_pressed);

        let down = decode_click_buttons(&[0x08, 0x01, 0x10, 0x00]).unwrap();
        assert!(!down.up_pressed && down.down_pressed);
    }

    #[test]
    fn missing_button_fields_read_as_released() {
        let empty = decode_click_buttons(&[]).unwrap();
        assert!(!empty.up_pressed && !empty.down_pressed);
    }

    /// Bytes captured from a Click v2 pod on 2026-08-05. If a refactor ever
    /// breaks these, the decoder has stopped agreeing with the hardware.
    #[test]
    fn captured_button_frames_decode_to_the_observed_bits() {
        let mask = |raw: &[u8]| {
            let frame = parse_frame(raw).unwrap();
            assert_eq!(frame.kind, MessageType::ButtonBitmask);
            decode_button_bitmask(frame.payload).unwrap()
        };

        let idle = mask(&[0x23, 0x08, 0xff, 0xff, 0xff, 0xff, 0x0f]);
        assert_eq!(idle.raw, 0xFFFF_FFFF);
        assert!(idle.is_idle());
        assert_eq!(idle.pressed_bits(), Vec::<u8>::new());

        assert_eq!(
            mask(&[0x23, 0x08, 0xfe, 0xff, 0xff, 0xff, 0x0f]).pressed_bits(),
            vec![0]
        );
        // Two buttons held at once — the mask is state, not an event code.
        assert_eq!(
            mask(&[0x23, 0x08, 0xfc, 0xff, 0xff, 0xff, 0x0f]).pressed_bits(),
            vec![0, 1]
        );
        assert_eq!(
            mask(&[0x23, 0x08, 0xf7, 0xff, 0xff, 0xff, 0x0f]).pressed_bits(),
            vec![3]
        );
        assert_eq!(
            mask(&[0x23, 0x08, 0xf6, 0xff, 0xff, 0xff, 0x0f]).pressed_bits(),
            vec![0, 3]
        );
        assert_eq!(
            mask(&[0x23, 0x08, 0xff, 0xfd, 0xff, 0xff, 0x0f]).pressed_bits(),
            vec![8]
        );
        assert_eq!(
            mask(&[0x23, 0x08, 0xf7, 0xfd, 0xff, 0xff, 0x0f]).pressed_bits(),
            vec![3, 8]
        );
        assert_eq!(
            mask(&[0x23, 0x08, 0xff, 0xdf, 0xff, 0xff, 0x0f]).pressed_bits(),
            vec![12]
        );
    }

    #[test]
    fn every_button_owns_a_distinct_bit_that_round_trips() {
        let mut seen = std::collections::HashSet::new();
        for b in Button::ALL {
            assert!(seen.insert(b.bit()), "{} reuses a bit", b.label());
            assert_eq!(Button::from_bit(b.bit()), Some(b));
        }
        // Bits 9-11 belong to nothing we have found.
        assert_eq!(Button::from_bit(9), None);
        assert_eq!(Button::from_bit(11), None);
        assert_eq!(Button::from_bit(31), None);
    }

    /// The A/Z pair that two captures disagreed on, settled by a dedicated
    /// alternating test that returned 4, 7, 4, 7, 4, 7.
    #[test]
    fn face_buttons_match_the_hardware_test() {
        assert_eq!(Button::A.bit(), 4);
        assert_eq!(Button::Z.bit(), 7);

        let a = decode_button_bitmask(&[0x08, 0xef, 0xff, 0xff, 0xff, 0x0f]).unwrap();
        assert_eq!(a.pressed_buttons(), vec![Button::A]);
        let z = decode_button_bitmask(&[0x08, 0xff, 0xfe, 0xff, 0xff, 0x0f]).unwrap();
        assert_eq!(z.pressed_buttons(), vec![Button::Z]);
    }

    /// Every button as captured on 2026-08-05, mask by mask.
    #[test]
    fn captured_masks_name_the_right_button() {
        for (raw, expected) in [
            (0xffff_fffe_u32, Button::Left),
            (0xffff_fffd, Button::Up),
            (0xffff_fffb, Button::Right),
            (0xffff_fff7, Button::Down),
            (0xffff_ffef, Button::A),
            (0xffff_ffdf, Button::B),
            (0xffff_ffbf, Button::Y),
            (0xffff_ff7f, Button::Z),
            (0xffff_feff, Button::Minus),
            (0xffff_efff, Button::Plus),
        ] {
            let mask = ButtonBitmask { raw };
            assert_eq!(
                mask.pressed_buttons(),
                vec![expected],
                "mask 0x{raw:08x} should be {}",
                expected.label()
            );
            assert!(mask.holds(expected));
        }
    }

    #[test]
    fn simultaneous_presses_name_every_button_held() {
        // Bits 0 and 1 clear together, as seen in the first capture.
        let mask = ButtonBitmask { raw: 0xffff_fffc };
        assert_eq!(mask.pressed_buttons(), vec![Button::Left, Button::Up]);
        assert!(ButtonBitmask { raw: u32::MAX }.pressed_buttons().is_empty());
    }

    fn mask(raw: u32) -> ButtonBitmask {
        ButtonBitmask { raw }
    }

    #[test]
    fn a_held_button_produces_one_press_not_ten() {
        let mut t = ButtonTracker::new();
        // The press edge.
        assert_eq!(
            t.update(mask(0xffff_efff)),
            vec![ButtonEvent { button: Button::Plus, pressed: true }]
        );
        // Held: the pod repeats the same mask at ~10 Hz and we stay silent.
        for _ in 0..10 {
            assert!(t.update(mask(0xffff_efff)).is_empty());
        }
        // The release edge.
        assert_eq!(
            t.update(mask(u32::MAX)),
            vec![ButtonEvent { button: Button::Plus, pressed: false }]
        );
        assert!(t.update(mask(u32::MAX)).is_empty());
    }

    #[test]
    fn one_frame_can_carry_several_edges() {
        let mut t = ButtonTracker::new();
        t.update(mask(0xffff_fffe)); // left down
        // left releases and up presses in the same frame.
        assert_eq!(
            t.update(mask(0xffff_fffd)),
            vec![
                ButtonEvent { button: Button::Left, pressed: false },
                ButtonEvent { button: Button::Up, pressed: true },
            ]
        );
        assert_eq!(t.held(), vec![Button::Up]);
    }

    #[test]
    fn reset_prevents_a_phantom_press_latching_across_a_disconnect() {
        let mut t = ButtonTracker::new();
        t.update(mask(0xffff_efff)); // `+` held as the link drops
        assert_eq!(t.held(), vec![Button::Plus]);

        t.reset();
        assert!(t.held().is_empty());
        // Reconnecting to an idle pod must not emit a stale release.
        assert!(t.update(mask(u32::MAX)).is_empty());
    }

    #[test]
    fn a_button_already_held_at_the_first_frame_reports_as_pressed() {
        let mut t = ButtonTracker::new();
        assert_eq!(
            t.update(mask(0xffff_ff7f)),
            vec![ButtonEvent { button: Button::Z, pressed: true }]
        );
    }

    #[test]
    fn a_button_frame_with_no_field_reads_as_idle() {
        assert!(decode_button_bitmask(&[]).unwrap().is_idle());
        // Out-of-range bits are not pressed rather than a panic.
        assert!(!ButtonBitmask { raw: 0 }.is_pressed(32));
        assert!(ButtonBitmask { raw: 0 }.is_pressed(31));
    }

    /// The battery frame exactly as the pod sent it.
    #[test]
    fn captured_battery_frame_decodes() {
        let frame = parse_frame(&[0x19, 0x10, 0x64]).unwrap();
        assert_eq!(frame.kind, MessageType::Battery);
        assert_eq!(decode_battery(frame.payload).unwrap(), Some(100));
    }

    /// The handshake reply exactly as the pod sent it — note `02 03`, not the
    /// `01 03` the public write-ups give.
    #[test]
    fn captured_ride_on_reply_is_recognised_despite_unexpected_suffix() {
        let reply = [0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e, 0x02, 0x03];
        assert!(is_ride_on_reply(&reply));
        assert_eq!(&reply[6..], RESPONSE_START);
    }

    #[test]
    fn battery_reads_the_first_field() {
        assert_eq!(decode_battery(&[0x08, 0x55]).unwrap(), Some(85));
        assert_eq!(decode_battery(&[]).unwrap(), None);
    }
}

/// Riding data from a *trainer* on the Zwift channel — message type `0x03`.
///
/// The D100 advertises no cadence over FTMS (its Fitness Machine Feature bits
/// claim average speed and power only), and the undeclared trailing bytes in
/// its Indoor Bike Data turned out to be wheel RPM restated — a fixed 73.8×
/// the declared speed, carrying no new information. Cadence is nevertheless
/// available: it is here, on the trainer's Zwift service.
///
/// Established by capture against `VANRYSEL-HT-2876` on 2026-08-05, holding two
/// known cadences. Five protobuf varints arrive at ~1 Hz, of which only two are
/// independent:
///
/// | field | meaning                                    |
/// |-------|--------------------------------------------|
/// | 1     | instantaneous power, watts                 |
/// | 2     | heart rate — always 0 with no strap paired |
/// | 3     | cadence, 0.1 rpm                           |
/// | 4     | duplicate of field 1                       |
/// | 5     | speed; a fixed 28.6× field 3               |
///
/// Field 5 being a constant multiple of field 3 is not a coincidence to be
/// decoded away: with a Zwift Cog there is one sprocket, so cadence and wheel
/// speed are mechanically locked and the ratio *is* the gearing. Observing that
/// constant is what identifies the pair.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct RidingData {
    pub power_w: u32,
    pub heart_rate_bpm: u32,
    /// Tenths of an rpm, as sent.
    pub cadence_drpm: u32,
    pub speed_raw: u32,
}

impl RidingData {
    pub fn cadence_rpm(&self) -> f32 {
        self.cadence_drpm as f32 / 10.0
    }

    /// Cadence-to-speed ratio, which on a single-cog drivetrain is fixed by the
    /// gearing. Useful for deriving the effective gear ratio empirically rather
    /// than asking the rider for tooth counts.
    pub fn speed_per_cadence(&self) -> Option<f32> {
        (self.cadence_drpm > 0).then(|| self.speed_raw as f32 / self.cadence_drpm as f32)
    }
}

/// Decode a type-`0x03` riding-data frame. Returns `None` if the payload is not
/// riding data or is malformed.
pub fn decode_riding_data(frame: &[u8]) -> Option<RidingData> {
    let (&kind, mut rest) = frame.split_first()?;
    if kind != 0x03 {
        return None;
    }
    let mut out = RidingData::default();
    while let Some((&tag, tail)) = rest.split_first() {
        // Every field here is varint-encoded (wire type 0).
        if tag & 0x07 != 0 {
            return None;
        }
        let (value, tail) = read_varint(tail)?;
        match tag >> 3 {
            1 => out.power_w = value,
            2 => out.heart_rate_bpm = value,
            3 => out.cadence_drpm = value,
            4 => {} // duplicate of field 1; nothing to learn from it
            5 => out.speed_raw = value,
            _ => {} // forward-compatible: ignore fields we do not know
        }
        rest = tail;
    }
    Some(out)
}

fn read_varint(bytes: &[u8]) -> Option<(u32, &[u8])> {
    let mut value: u64 = 0;
    for (i, &b) in bytes.iter().enumerate() {
        // A u32 field cannot need more than five groups of seven bits.
        if i >= 5 {
            return None;
        }
        value |= u64::from(b & 0x7f) << (7 * i);
        if b & 0x80 == 0 {
            return Some((u32::try_from(value).ok()?, &bytes[i + 1..]));
        }
    }
    None
}

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

    /// Frames captured from the real trainer, with the values `probe` decoded.
    const CAPTURED: &[(&str, u32, u32, u32)] = &[
        // hex,                          power, cadence_drpm, speed
        ("030815100018ab01201528ad26", 21, 171, 4909),
        ("03080c1000186b200c28f917", 12, 107, 3065),
        ("0308041000184320042886 0f", 4, 67, 1926),
        ("0308021000182f200228cf0a", 2, 47, 1359),
        ("0308001000180020002800", 0, 0, 0),
    ];

    #[test]
    fn decodes_frames_captured_from_the_trainer() {
        for (hex, power, cadence, speed) in CAPTURED {
            let bytes: Vec<u8> = (0..hex.replace(' ', "").len())
                .step_by(2)
                .map(|i| u8::from_str_radix(&hex.replace(' ', "")[i..i + 2], 16).unwrap())
                .collect();
            let d = decode_riding_data(&bytes).expect("should decode");
            assert_eq!(d.power_w, *power, "power in {hex}");
            assert_eq!(d.cadence_drpm, *cadence, "cadence in {hex}");
            assert_eq!(d.speed_raw, *speed, "speed in {hex}");
        }
    }

    #[test]
    fn the_cog_locks_cadence_to_speed_at_a_constant_ratio() {
        // The signature that identifies the cadence/speed pair: one sprocket
        // means the ratio cannot vary, whatever the rider does.
        let ratios: Vec<f32> = CAPTURED
            .iter()
            .filter(|(_, _, c, _)| *c > 0)
            .map(|(_, _, c, s)| *s as f32 / *c as f32)
            .collect();
        let first = ratios[0];
        for r in &ratios {
            assert!((r - first).abs() / first < 0.02, "ratio drifted: {ratios:?}");
        }
        assert!((first - 28.6).abs() < 0.5, "expected ~28.6, got {first}");
    }

    #[test]
    fn cadence_reads_in_rpm() {
        let d = decode_riding_data(&[0x03, 0x18, 0xb4, 0x04]).unwrap();
        assert_eq!(d.cadence_drpm, 564);
        assert!((d.cadence_rpm() - 56.4).abs() < 0.001);
    }

    #[test]
    fn a_non_riding_frame_is_rejected_not_misread() {
        assert!(decode_riding_data(&[0x23, 0x08, 0x01]).is_none());
        assert!(decode_riding_data(&[]).is_none());
    }

    #[test]
    fn a_truncated_varint_does_not_panic() {
        // NFR-4: malformed input must never take the app down.
        assert!(decode_riding_data(&[0x03, 0x08, 0xff]).is_none());
    }
}
