//! Heart rate monitor client: decoder and connection actor.
//!
//! Structurally a sibling of [`crate::click`]: one task owns the peripheral,
//! callers hold a cheap handle and receive [`HeartRateEvent`]s on a broadcast
//! channel. The wire format lives in this module too, as pure functions over
//! bytes, because it is a single characteristic and does not earn a module of
//! its own.
//!
//! ```text
//!   caller ◄──broadcast── HeartRateEvent ◄── actor ◄─notify── HRM / watch
//! ```
//!
//! Works with anything that exposes the standard Heart Rate Service (`0x180D`)
//! — a chest strap, or a sports watch with *Broadcast Heart Rate* switched on.
//! A watch in broadcast mode accepts a single central, so connecting it here
//! takes it away from any other app; that is a property of the watch, not of
//! this client.
//!
//! Read-only in every sense: nothing is ever written to the device, so — like
//! the Click and unlike the trainer — there is no safety sequence, only the
//! SAF-9 obligation to actually close the link on the way out.

use std::future::Future;
use std::time::Duration;

use btleplug::api::{Characteristic, Peripheral as _};
use btleplug::platform::{Adapter, Peripheral};
use futures::{Stream, StreamExt};
use tokio::sync::{broadcast, mpsc, oneshot};

use crate::client::{Backoff, InFlight, DISCONNECT_TIMEOUT};
use crate::error::FtmsError;
use crate::indoor_bike_data::hex;
use crate::scan::{self, DiscoveredDevice, ScanKind};
use crate::uuids;

// ---------------------------------------------------------------------------
// Wire format (pure)
// ---------------------------------------------------------------------------

/// One decoded Heart Rate Measurement (`0x2A37`) notification.
///
/// Only what the app uses. The characteristic can also carry energy expended
/// and RR intervals; both are skipped, not rejected — a device that sends them
/// still decodes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HeartRateMeasurement {
    /// Beats per minute. `u16` because the flags allow it, though anything a
    /// human produces fits in a `u8`.
    pub bpm: u16,
    /// Skin contact, where the device reports the feature at all. `None` means
    /// "not supported", not "no contact" — the two must not be conflated, or
    /// every strap without the feature reads as fallen off.
    pub sensor_contact: Option<bool>,
}

/// Why a Heart Rate Measurement frame would not decode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum HrDecodeError {
    #[error("empty heart rate measurement")]
    Empty,
    #[error("heart rate measurement of {len} bytes is too short for its flags (0x{flags:02x})")]
    TooShort { flags: u8, len: usize },
}

/// Decode a Heart Rate Measurement (`0x2A37`) value.
///
/// Layout per the Bluetooth SIG GATT specification: a flags byte, then the
/// heart rate as `u8` or `u16` little-endian depending on flags bit 0. Bits
/// 1–2 describe sensor contact: bit 2 says the feature is supported, bit 1
/// says contact is detected.
pub fn decode_heart_rate(data: &[u8]) -> Result<HeartRateMeasurement, HrDecodeError> {
    let (&flags, rest) = data.split_first().ok_or(HrDecodeError::Empty)?;

    let bpm = if flags & 0x01 == 0 {
        *rest.first().ok_or(HrDecodeError::TooShort {
            flags,
            len: data.len(),
        })? as u16
    } else {
        match rest {
            [lo, hi, ..] => u16::from_le_bytes([*lo, *hi]),
            _ => {
                return Err(HrDecodeError::TooShort {
                    flags,
                    len: data.len(),
                })
            }
        }
    };

    let sensor_contact = match flags & 0x06 {
        0x06 => Some(true),
        0x04 => Some(false),
        _ => None,
    };

    Ok(HeartRateMeasurement {
        bpm,
        sensor_contact,
    })
}

// ---------------------------------------------------------------------------
// Selection and configuration
// ---------------------------------------------------------------------------

/// How to pick a heart rate monitor out of a scan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HrSelector {
    /// A specific device, by address — what the app uses, since the scan has
    /// already identified it. Matched without insisting on the advertisement:
    /// a device mid-connection may not be advertising its services.
    Address(String),
    /// Any peripheral advertising the Heart Rate Service (`0x180D`).
    Any,
}

impl HrSelector {
    pub fn matches(&self, d: &DiscoveredDevice) -> bool {
        self.matches_parts(
            &d.address,
            d.services.contains(&uuids::HEART_RATE_SERVICE),
            &format!("{:?}", d.id),
        )
    }

    /// The matching rule, factored out so it can be unit-tested without a
    /// `PeripheralId` (which only the platform backend can construct).
    pub(crate) fn matches_parts(
        &self,
        address: &str,
        advertises_hr: bool,
        id_debug: &str,
    ) -> bool {
        match self {
            HrSelector::Address(a) => {
                address.eq_ignore_ascii_case(a)
                    || id_debug.to_lowercase().contains(&a.to_lowercase())
            }
            HrSelector::Any => advertises_hr,
        }
    }

    /// Human-readable, for error messages the rider will actually read.
    pub fn describe(&self) -> String {
        match self {
            HrSelector::Address(a) => format!("heart rate monitor at {a}"),
            HrSelector::Any => "any heart rate monitor".to_string(),
        }
    }
}

/// Tunables for [`HeartRateClient`].
#[derive(Debug, Clone)]
pub struct HeartRateConfig {
    /// How long to look before giving up. A broadcasting device advertises
    /// continuously — unlike a Click — so this needs no extra patience.
    pub scan_timeout: Duration,
    pub backoff: Backoff,
    pub channel_capacity: usize,
}

impl Default for HeartRateConfig {
    fn default() -> Self {
        Self {
            scan_timeout: Duration::from_secs(15),
            backoff: Backoff::default(),
            channel_capacity: 64,
        }
    }
}

// ---------------------------------------------------------------------------
// Events and handle
// ---------------------------------------------------------------------------

/// Everything the app learns from a heart rate monitor.
#[derive(Debug, Clone, PartialEq)]
pub enum HeartRateEvent {
    Connected {
        address: String,
        name: Option<String>,
        /// Read once at connect, where the device exposes `0x2A19` at all.
        battery_percent: Option<u8>,
    },
    /// The link dropped. The actor is retrying — this is not terminal.
    Disconnected,
    /// Reconnecting ran out of attempts and the actor has stopped (FR-1.11).
    /// Terminal: nothing further arrives on this stream.
    GaveUp { attempts: u32 },
    /// A decoded measurement.
    Measurement {
        bpm: u16,
        sensor_contact: Option<bool>,
    },
}

enum Cmd {
    Shutdown { reply: oneshot::Sender<()> },
}

/// Cheap, cloneable handle to a heart rate monitor session.
#[derive(Clone)]
pub struct HeartRateClient {
    cmd_tx: mpsc::Sender<Cmd>,
    events_tx: broadcast::Sender<HeartRateEvent>,
    address: String,
    name: Option<String>,
    battery_percent: Option<u8>,
}

impl HeartRateClient {
    /// Carried on the handle rather than left to the event stream, because the
    /// first `Connected` is sent before the caller has had a chance to
    /// subscribe — same reasoning as [`crate::click::ClickClient::address`].
    pub fn address(&self) -> &str {
        &self.address
    }

    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    /// Battery level at connect time, where the device reported one.
    pub fn battery_percent(&self) -> Option<u8> {
        self.battery_percent
    }

    /// Connect to a heart rate monitor and start streaming measurements.
    ///
    /// Returns once the measurement characteristic is subscribed, so an `Ok`
    /// means data is genuinely on its way — not merely that a link exists.
    pub async fn connect(
        selector: HrSelector,
        config: HeartRateConfig,
    ) -> Result<Self, FtmsError> {
        let adapter = scan::default_adapter().await?;
        Self::connect_with_adapter(adapter, selector, config).await
    }

    /// As [`HeartRateClient::connect`], but abandoned as soon as `cancel`
    /// resolves. Returns `Ok(None)` when it was cancelled; any link the
    /// abandoned attempt had opened is closed before this returns (SAF-9,
    /// FR-1.10).
    pub async fn connect_cancellable(
        selector: HrSelector,
        config: HeartRateConfig,
        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!("hr: connect attempt cancelled");
                in_flight.abandon().await;
                Ok(None)
            }
        }
    }

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

    async fn connect_on(
        adapter: Adapter,
        selector: HrSelector,
        config: HeartRateConfig,
        in_flight: &InFlight,
    ) -> Result<Self, FtmsError> {
        let (events_tx, _) = broadcast::channel(config.channel_capacity);
        let (cmd_tx, cmd_rx) = mpsc::channel(8);

        let (session, notifications) =
            open_session(&adapter, &selector, &config, in_flight).await?;
        let (address, name, battery_percent) = (
            session.address.clone(),
            session.name.clone(),
            session.battery_percent,
        );
        // Sent for symmetry with a reconnect; the same facts ride out on the
        // handle below because nobody is subscribed yet.
        let _ = events_tx.send(HeartRateEvent::Connected {
            address: address.clone(),
            name: name.clone(),
            battery_percent,
        });

        let actor = Actor {
            adapter,
            // Reconnect to *this* device, not to whatever HRM now answers
            // first — two straps in a household is not exotic.
            selector: if address.is_empty() {
                selector
            } else {
                HrSelector::Address(address.clone())
            },
            config,
            events_tx: events_tx.clone(),
        };
        tokio::spawn(actor.run(cmd_rx, session, Box::pin(notifications)));

        Ok(Self {
            cmd_tx,
            events_tx,
            address,
            name,
            battery_percent,
        })
    }

    /// Subscribe to events. Late subscribers see only what arrives after they
    /// subscribe.
    pub fn events(&self) -> broadcast::Receiver<HeartRateEvent> {
        self.events_tx.subscribe()
    }

    /// Close the link, and wait for it to actually be closed (SAF-9).
    /// Idempotent — shutting down a stopped client is a no-op.
    pub async fn shutdown(&self) {
        let (reply, done) = oneshot::channel();
        if self.cmd_tx.send(Cmd::Shutdown { reply }).await.is_err() {
            // The actor is already gone; it tore the link down on its way out.
            return;
        }
        let _ = done.await;
    }
}

// ---------------------------------------------------------------------------
// Session and actor
// ---------------------------------------------------------------------------

/// Boxed so the initial connection and every reconnect share one type.
type Notifications = std::pin::Pin<
    Box<dyn Stream<Item = btleplug::api::ValueNotification> + Send>,
>;

struct Session {
    peripheral: Peripheral,
    address: String,
    name: Option<String>,
    battery_percent: Option<u8>,
    measurement: Characteristic,
}

/// Find the device, connect and subscribe to Heart Rate Measurement.
async fn open_session(
    adapter: &Adapter,
    selector: &HrSelector,
    config: &HeartRateConfig,
    in_flight: &InFlight,
) -> Result<(Session, Notifications), FtmsError> {
    // Unfiltered: an address selector must match a device whatever it
    // advertises, and some backends ignore service filters anyway.
    let peripheral = scan::find_matching(
        adapter,
        ScanKind::All,
        config.scan_timeout,
        &selector.describe(),
        |d| selector.matches(d),
    )
    .await?;
    // Cancelling past this point would otherwise strand the link (FR-1.10).
    in_flight.hold(peripheral.clone());

    match setup_session(peripheral.clone()).await {
        Ok(session) => {
            in_flight.released();
            Ok(session)
        }
        Err(e) => {
            // A failure after `connect()` leaves a live GATT link behind, and a
            // watch in broadcast mode accepts one central — held, it will not
            // advertise for the retry, so the retry loop would never succeed.
            tracing::debug!(error = %e, "hr: session setup failed; disconnecting");
            let _ = peripheral.disconnect().await;
            in_flight.released();
            Err(e)
        }
    }
}

async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications), FtmsError> {
    // 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?;
    peripheral.discover_services().await?;

    let chars = peripheral.characteristics();
    // Prefer the characteristic inside the Heart Rate Service, but take a
    // bare 0x2A37 anywhere — the same leniency the FTMS client extends.
    let measurement = chars
        .iter()
        .find(|c| {
            c.uuid == uuids::HEART_RATE_MEASUREMENT
                && c.service_uuid == uuids::HEART_RATE_SERVICE
        })
        .or_else(|| chars.iter().find(|c| c.uuid == uuids::HEART_RATE_MEASUREMENT))
        .cloned()
        .ok_or(FtmsError::MissingCharacteristic(
            "Heart Rate Measurement (0x2A37)",
        ))?;

    let notifications = peripheral.notifications().await?;
    peripheral.subscribe(&measurement).await?;

    // Best effort, once: straps report battery, watches in broadcast mode
    // usually do not. A failure here must not cost the session.
    let battery_percent = match chars.iter().find(|c| c.uuid == uuids::BATTERY_LEVEL) {
        Some(c) => peripheral
            .read(c)
            .await
            .ok()
            .and_then(|v| v.first().copied())
            .filter(|p| *p <= 100),
        None => None,
    };

    let described = scan::describe(&peripheral).await;
    Ok((
        Session {
            address: described
                .as_ref()
                .map(|d| d.address.clone())
                .unwrap_or_default(),
            name: described.and_then(|d| d.name),
            battery_percent,
            peripheral,
            measurement,
        },
        Box::pin(notifications),
    ))
}

struct Actor {
    adapter: Adapter,
    selector: HrSelector,
    config: HeartRateConfig,
    events_tx: broadcast::Sender<HeartRateEvent>,
}

impl Actor {
    async fn run(
        self,
        mut cmd_rx: mpsc::Receiver<Cmd>,
        mut current: Session,
        mut notifications: Notifications,
    ) {
        let mut attempt = 0u32;

        loop {
            let dropped = tokio::select! {
                cmd = cmd_rx.recv() => {
                    match cmd {
                        Some(Cmd::Shutdown { reply }) => {
                            self.teardown(&current).await;
                            // Answered only once the link is genuinely closed
                            // (SAF-9).
                            let _ = reply.send(());
                            return;
                        }
                        None => {
                            self.teardown(&current).await;
                            return;
                        }
                    }
                }
                frame = notifications.next() => match frame {
                    Some(n) => { self.handle(n.uuid, &n.value); false }
                    // The stream ending is how btleplug reports a dropped link.
                    None => true,
                },
            };

            if !dropped {
                continue;
            }

            tracing::warn!(
                selector = %self.selector.describe(),
                "hr: notification stream ended — link dropped"
            );
            let _ = self.events_tx.send(HeartRateEvent::Disconnected);

            // FR-1.6: reconnect with backoff. A watch that left broadcast mode
            // or a strap taken off will simply not be found, which is normal.
            loop {
                if self.config.backoff.exhausted(attempt) {
                    tracing::warn!("hr: giving up after {attempt} reconnect attempts");
                    // Say so before going away (FR-1.11): a silent exit strands
                    // the supervisor holding a handle whose actor is gone.
                    let _ = self.events_tx.send(HeartRateEvent::GaveUp { attempts: attempt });
                    return;
                }
                let delay = self.config.backoff.delay(attempt);
                attempt += 1;

                // Both the backoff and the attempt stay answerable to shutdown
                // (FR-1.10, SAF-9) — an attempt runs for up to `scan_timeout`.
                let in_flight = InFlight::default();
                let outcome = {
                    let attempting = async {
                        tokio::time::sleep(delay).await;
                        open_session(&self.adapter, &self.selector, &self.config, &in_flight)
                            .await
                    };
                    tokio::pin!(attempting);
                    tokio::select! {
                        biased;
                        cmd = cmd_rx.recv() => Err(cmd),
                        result = &mut attempting => Ok(result),
                    }
                };

                let result = match outcome {
                    Ok(result) => result,
                    Err(cmd) => {
                        in_flight.abandon().await;
                        if let Some(Cmd::Shutdown { reply }) = cmd {
                            let _ = reply.send(());
                        }
                        return;
                    }
                };

                match result {
                    Ok((session, stream)) => {
                        let _ = self.events_tx.send(HeartRateEvent::Connected {
                            address: session.address.clone(),
                            name: session.name.clone(),
                            battery_percent: session.battery_percent,
                        });
                        current = session;
                        notifications = stream;
                        attempt = 0;
                        break;
                    }
                    Err(e) => tracing::debug!(error = %e, "hr: reconnect failed"),
                }
            }
        }
    }

    /// Decode one notification into an event.
    fn handle(&self, uuid: uuid::Uuid, raw: &[u8]) {
        if uuid != uuids::HEART_RATE_MEASUREMENT {
            tracing::trace!(%uuid, raw = %hex(raw), "hr: notification on an unexpected characteristic");
            return;
        }
        match decode_heart_rate(raw) {
            Ok(m) => {
                let _ = self.events_tx.send(HeartRateEvent::Measurement {
                    bpm: m.bpm,
                    sensor_contact: m.sensor_contact,
                });
            }
            // Logged, never fatal (NFR-4).
            Err(e) => tracing::debug!(raw = %hex(raw), error = %e, "hr: bad measurement frame"),
        }
    }

    /// Close the link. Bounded: this runs on the app's exit path (NFR-9).
    async fn teardown(&self, session: &Session) {
        tracing::debug!(measurement = %session.measurement.uuid, "hr: closing the link");
        // No unsubscribe first. It is optional — dropping the link clears the
        // strap's CCCD anyway — and on the exit path it competes for the same
        // budget as the disconnect that actually matters. The Click actor lost
        // that race and left links open in BlueZ; this path is one
        // characteristic rather than five, but the failure is the same shape.
        match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await {
            Ok(Ok(())) => tracing::info!("hr: disconnected"),
            Ok(Err(e)) => tracing::debug!(error = %e, "hr: disconnect failed"),
            Err(_) => tracing::warn!(
                "hr: disconnect timed out — the link may be left open, and a stale link makes \
                 the strap look connected while sending nothing"
            ),
        }
        let _ = self.events_tx.send(HeartRateEvent::Disconnected);
    }
}

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

    #[test]
    fn a_plain_u8_measurement_decodes() {
        // Flags 0x00: u8 heart rate, no contact feature, nothing else.
        let m = decode_heart_rate(&[0x00, 142]).unwrap();
        assert_eq!(m.bpm, 142);
        assert_eq!(m.sensor_contact, None);
    }

    #[test]
    fn a_u16_measurement_decodes_little_endian() {
        // Flags 0x01: u16 heart rate. 0x0121 = 289 — absurd for a human but
        // the format allows it, and the decoder is a decoder, not a referee.
        let m = decode_heart_rate(&[0x01, 0x21, 0x01]).unwrap();
        assert_eq!(m.bpm, 289);
    }

    #[test]
    fn sensor_contact_is_three_states_not_two() {
        // Bit 2 = feature supported, bit 1 = contact detected. A strap without
        // the feature must read `None`, not "fallen off".
        assert_eq!(
            decode_heart_rate(&[0x06, 100]).unwrap().sensor_contact,
            Some(true)
        );
        assert_eq!(
            decode_heart_rate(&[0x04, 100]).unwrap().sensor_contact,
            Some(false)
        );
        assert_eq!(decode_heart_rate(&[0x00, 100]).unwrap().sensor_contact, None);
        // Bit 1 without bit 2 is "feature not supported" per the spec.
        assert_eq!(decode_heart_rate(&[0x02, 100]).unwrap().sensor_contact, None);
    }

    #[test]
    fn trailing_fields_are_skipped_not_rejected() {
        // Flags 0x18: energy expended + RR intervals present. Real straps
        // (Garmin HRM-Dual, Polar H10) send these; the bpm must still decode.
        let m = decode_heart_rate(&[0x18, 155, 0x10, 0x00, 0x40, 0x03]).unwrap();
        assert_eq!(m.bpm, 155);
    }

    #[test]
    fn short_frames_are_errors_not_panics() {
        // NFR-4: a malformed packet is a value, never a crash.
        assert_eq!(decode_heart_rate(&[]), Err(HrDecodeError::Empty));
        assert!(matches!(
            decode_heart_rate(&[0x00]),
            Err(HrDecodeError::TooShort { .. })
        ));
        // u16 flag with only one byte of payload.
        assert!(matches!(
            decode_heart_rate(&[0x01, 142]),
            Err(HrDecodeError::TooShort { .. })
        ));
    }

    #[test]
    fn selector_any_requires_the_heart_rate_service() {
        let s = HrSelector::Any;
        assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", true, ""));
        assert!(!s.matches_parts("aa:bb:cc:dd:ee:ff", false, ""));
    }

    #[test]
    fn selector_address_ignores_the_advertisement() {
        // Reconnect runs against a device that may not be advertising its
        // services; the address alone identifies it.
        let s = HrSelector::Address("AA:BB:CC:DD:EE:FF".into());
        assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", false, ""));
        assert!(!s.matches_parts("11:22:33:44:55:66", true, ""));
    }

    #[test]
    fn selector_address_also_matches_an_opaque_platform_id() {
        // macOS gives UUID-shaped PeripheralIds rather than MACs.
        let s = HrSelector::Address("1E2F3A4B".into());
        assert!(s.matches_parts("", false, "PeripheralId(1e2f3a4b-....)"));
    }
}
