//! The heart rate supervisor: the app's single owner of a [`HeartRateClient`].
//!
//! The same shape as [`crate::controller`], with one slot instead of two — the
//! Tauri commands hold a `Mutex` and may never `.await` a radio, so all BLE
//! work happens in one background task they reach over a channel.
//!
//! ```text
//!   commands ──connect/disconnect──► [supervisor task] ──► HeartRateClient
//!   ride loop ◄──watch<Option<u8>>──────────┘ (bpm)
//!   device loop ◄──watch<HeartRateStatus>───┘
//! ```
//!
//! The bpm channel follows the trainer's "never freeze on stale data" rule:
//! a monitor that goes quiet — strap off, watch out of broadcast mode — has
//! its published reading cleared rather than held, so the ride records *no*
//! heart rate rather than a plausible-looking flatline of the last real value.

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

use bikecontrol_ble::{
    Backoff, FtmsError, HeartRateClient, HeartRateConfig, HeartRateEvent, HrSelector,
};
use bikecontrol_core::types::ConnectionState;
use serde::Serialize;
use tokio::sync::{broadcast, mpsc, oneshot, watch};

/// How long the monitor may stay silent before the published bpm is cleared.
///
/// A strap notifies at about 1 Hz, so eight seconds is ~8 missed frames —
/// enough slack for a lossy link, short enough that a strap taken off does not
/// keep writing its last reading into the FIT file.
const STALE_AFTER: Duration = Duration::from_secs(8);
/// Housekeeping tick — staleness only, so it can be lazy.
const HOUSEKEEPING: Duration = Duration::from_secs(2);
/// Upper bound on closing the link at exit. There is no reset sequence, only
/// an unsubscribe and a disconnect (SAF-9, NFR-9).
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// How long auto-reconnect keeps chasing the monitor before giving up
/// (FR-1.11). The trainer's budget, not the Click's: a broadcasting device
/// advertises continuously, so "not found" here means it is genuinely gone.
const RECONNECT_ATTEMPTS: u32 = 20;

/// The heart rate configuration this app rides with.
fn hr_config() -> HeartRateConfig {
    HeartRateConfig {
        backoff: Backoff {
            max_attempts: Some(RECONNECT_ATTEMPTS),
            ..Backoff::default()
        },
        ..HeartRateConfig::default()
    }
}

/// What the UI needs to know about the heart rate link (FR-1.7, FR-9.2).
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HeartRateStatus {
    pub state: ConnectionState,
    pub address: Option<String>,
    pub name: Option<String>,
    /// The live reading, also published on the bpm watch channel. Carried here
    /// too so the connection screen can show proof the data is flowing — a
    /// monitor that is connected but silent looks exactly like a working one
    /// otherwise.
    pub bpm: Option<u8>,
    pub battery_percent: Option<u8>,
    /// Human-readable failure, rendered verbatim (FR-9.2).
    pub error: Option<String>,
}

impl Default for HeartRateStatus {
    fn default() -> Self {
        Self {
            state: ConnectionState::Idle,
            address: None,
            name: None,
            bpm: None,
            battery_percent: None,
            error: None,
        }
    }
}

impl HeartRateStatus {
    pub fn is_attached(&self) -> bool {
        !matches!(
            self.state,
            ConnectionState::Idle | ConnectionState::Lost { .. }
        )
    }
}

enum Cmd {
    /// Connect to a monitor. `address` when the scanner has already seen it,
    /// which is both faster and unambiguous.
    Connect { address: Option<String> },
    Disconnect,
    /// Close the link and stop, answering only once it is actually closed.
    Shutdown { reply: SyncSender<()> },
}

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

/// Cheap, cloneable handle to the supervisor.
#[derive(Clone)]
pub struct HeartRateHandle {
    cmd_tx: mpsc::Sender<Cmd>,
    status_rx: watch::Receiver<HeartRateStatus>,
    bpm_rx: watch::Receiver<Option<u8>>,
    /// Shared, so every clone of the handle sees the link is already shut.
    shut_down: Arc<AtomicBool>,
}

impl HeartRateHandle {
    /// Start the supervisor task. Needs a Tokio runtime, which
    /// `tauri::async_runtime` provides before the app is built.
    pub fn spawn() -> Self {
        let (cmd_tx, cmd_rx) = mpsc::channel(16);
        let (status_tx, status_rx) = watch::channel(HeartRateStatus::default());
        let (bpm_tx, bpm_rx) = watch::channel(None);

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

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

    /// The live reading, for the ride engine. Cleared while disconnected or
    /// stale, never a held-over value.
    pub fn bpm(&self) -> watch::Receiver<Option<u8>> {
        self.bpm_rx.clone()
    }

    pub fn connect(&self, address: Option<String>) {
        let _ = self.cmd_tx.try_send(Cmd::Connect { address });
    }

    pub fn disconnect(&self) {
        let _ = self.cmd_tx.try_send(Cmd::Disconnect);
    }

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

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

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

// ---------------------------------------------------------------------------
// Supervisor
// ---------------------------------------------------------------------------

async fn run(
    mut cmd_rx: mpsc::Receiver<Cmd>,
    status_tx: watch::Sender<HeartRateStatus>,
    bpm_tx: watch::Sender<Option<u8>>,
) {
    let mut client: Option<HeartRateClient> = None;
    let mut events: Option<broadcast::Receiver<HeartRateEvent>> = None;
    // Held while a connect is in flight; sending on it — or dropping it —
    // abandons the attempt (FR-1.10).
    let mut cancel: Option<oneshot::Sender<()>> = None;
    let mut generation: u64 = 0;
    let mut last_measurement: Option<Instant> = None;

    let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(2);
    let mut housekeeping = tokio::time::interval(HOUSEKEEPING);

    loop {
        tokio::select! {
            cmd = cmd_rx.recv() => {
                let Some(cmd) = cmd else {
                    // Every handle dropped: nobody can ask for anything again.
                    shutdown(client.take(), &bpm_tx).await;
                    return;
                };
                match cmd {
                    Cmd::Connect { address } => {
                        // A second click while a search is running means
                        // "connect", which is what is already happening — unless
                        // it names a different monitor, which takes over.
                        let current = status_tx.borrow().address.clone();
                        let same = address.is_none()
                            || address.as_deref().is_some_and(|a| {
                                current.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(a))
                            });
                        if same && (cancel.is_some() || client.is_some()) {
                            tracing::debug!("hr: already connected or connecting");
                            continue;
                        }
                        // Switching monitors: close the old link first (SAF-9).
                        if let Some(c) = cancel.take() {
                            let _ = c.send(());
                        }
                        generation += 1;
                        events = None;
                        last_measurement = None;
                        if let Some(c) = client.take() {
                            c.shutdown().await;
                        }
                        let _ = bpm_tx.send(None);

                        let selector = match address.clone().or(current) {
                            Some(a) if !a.trim().is_empty() => HrSelector::Address(a),
                            _ => HrSelector::Any,
                        };
                        tracing::info!(selector = %selector.describe(), "hr: connecting");
                        cancel = Some(start_attempt(generation, selector, &attempt_tx));
                        let _ = status_tx.send(HeartRateStatus {
                            state: ConnectionState::Connecting,
                            address,
                            ..HeartRateStatus::default()
                        });
                    }
                    Cmd::Disconnect => {
                        if let Some(c) = cancel.take() {
                            let _ = c.send(());
                        }
                        generation += 1;
                        events = None;
                        last_measurement = None;
                        if let Some(c) = client.take() {
                            c.shutdown().await;
                        }
                        let _ = bpm_tx.send(None);
                        // Keep who it was: a connected monitor stops appearing
                        // in scans, so wiping the address would leave the rider
                        // nothing to click on to reconnect (FR-1.12).
                        let address = status_tx.borrow().address.clone();
                        let name = status_tx.borrow().name.clone();
                        let _ = status_tx.send(HeartRateStatus {
                            address,
                            name,
                            ..HeartRateStatus::default()
                        });
                    }
                    Cmd::Shutdown { reply } => {
                        if let Some(c) = cancel.take() {
                            let _ = c.send(());
                        }
                        shutdown(client.take(), &bpm_tx).await;
                        let _ = status_tx.send(HeartRateStatus::default());
                        // Answered only once the link is genuinely closed, so a
                        // caller blocking the app's exit on this knows what it
                        // waited for (SAF-9).
                        let _ = reply.send(());
                        return;
                    }
                }
            }

            Some(attempt) = attempt_rx.recv() => {
                if attempt.generation != generation {
                    // Superseded — by a disconnect or a newer request. A client
                    // that arrived anyway owns a live GATT link, and dropping it
                    // would leave the monitor held (SAF-9).
                    if let Ok(Some(c)) = attempt.outcome {
                        tracing::debug!("hr: stale connect; closing");
                        tokio::spawn(async move { c.shutdown().await });
                    }
                    continue;
                }
                cancel = None;
                match attempt.outcome {
                    Ok(Some(c)) => {
                        tracing::info!(
                            address = c.address(),
                            name = c.name().unwrap_or("(no name)"),
                            "hr: connected"
                        );
                        events = Some(c.events());
                        last_measurement = Some(Instant::now());
                        let _ = status_tx.send(HeartRateStatus {
                            state: ConnectionState::Connected,
                            address: Some(c.address().to_string()),
                            name: c.name().map(str::to_owned),
                            battery_percent: c.battery_percent(),
                            ..HeartRateStatus::default()
                        });
                        client = Some(c);
                    }
                    Ok(None) => {
                        tracing::info!("hr: connect abandoned");
                        status_tx.send_modify(|s| s.state = ConnectionState::Idle);
                    }
                    Err(e) => {
                        tracing::warn!(error = %e, "hr: connect failed");
                        status_tx.send_modify(|s| {
                            s.state = ConnectionState::Idle;
                            s.error = Some(connect_hint(&e));
                        });
                    }
                }
            }

            // Only polled while there is a stream; `recv` on a `None` receiver
            // would busy-loop, so the branch is disabled instead.
            event = async { events.as_mut().unwrap().recv().await }, if events.is_some() => {
                match event {
                    Ok(HeartRateEvent::Measurement { bpm, sensor_contact }) => {
                        last_measurement = Some(Instant::now());
                        // `Some(false)` is the strap saying it has fallen off;
                        // its reading is noise and recording it would be worse
                        // than recording nothing.
                        let reading = (sensor_contact != Some(false))
                            .then_some(bpm.min(u8::MAX as u16) as u8);
                        let _ = bpm_tx.send(reading);
                        status_tx.send_modify(|s| {
                            // The first frame after a dropout is the only notice
                            // that the link is back.
                            if s.state == ConnectionState::Reconnecting {
                                s.state = ConnectionState::Connected;
                                s.error = None;
                            }
                            if s.bpm != reading {
                                s.bpm = reading;
                            }
                        });
                    }
                    Ok(HeartRateEvent::Connected { address, name, battery_percent }) => {
                        // A reconnect inside the client actor.
                        last_measurement = Some(Instant::now());
                        status_tx.send_modify(|s| {
                            s.state = ConnectionState::Connected;
                            s.error = None;
                            if !address.is_empty() {
                                s.address = Some(address.clone());
                            }
                            s.name = name.clone();
                            s.battery_percent = battery_percent;
                        });
                    }
                    Ok(HeartRateEvent::Disconnected) => {
                        let _ = bpm_tx.send(None);
                        status_tx.send_modify(|s| {
                            // Only a live link "reconnects"; a teardown on the
                            // way to Idle must not flip the state back.
                            if s.state == ConnectionState::Connected {
                                s.state = ConnectionState::Reconnecting;
                            }
                            s.bpm = None;
                        });
                    }
                    Ok(HeartRateEvent::GaveUp { attempts }) => {
                        // Terminal: the actor has stopped (FR-1.11).
                        tracing::warn!(attempts, "hr: reconnect gave up");
                        events = None;
                        client = None;
                        last_measurement = None;
                        let _ = bpm_tx.send(None);
                        status_tx.send_modify(|s| {
                            s.state = ConnectionState::Lost { reason: "stopped answering".into() };
                            s.bpm = None;
                            s.error = Some(format!(
                                "Gave up reaching the heart rate monitor after {attempts} \
                                 attempts. Check it is on — a watch must have Broadcast Heart \
                                 Rate enabled — then connect again."
                            ));
                        });
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        tracing::debug!(skipped = n, "hr: event consumer lagged");
                    }
                    Err(broadcast::error::RecvError::Closed) => {
                        // The actor is gone without a GaveUp — should not
                        // happen, but a closed stream must not spin the select.
                        events = None;
                    }
                }
            }

            _ = housekeeping.tick() => {
                let quiet = client.is_some()
                    && last_measurement.is_some_and(|t| t.elapsed() >= STALE_AFTER);
                if quiet && bpm_tx.borrow().is_some() {
                    // Clear rather than hold: a strap taken off must not keep
                    // writing its last reading into the FIT file (FR-1.8).
                    tracing::info!("hr: no measurement for {STALE_AFTER:?} — clearing the reading");
                    let _ = bpm_tx.send(None);
                    status_tx.send_modify(|s| s.bpm = None);
                }
            }
        }
    }
}

/// Spawn the connect in its own task, so a fifteen-second search cannot hold
/// up a disconnect or a quit (FR-1.10).
fn start_attempt(
    generation: u64,
    selector: HrSelector,
    results: &mpsc::Sender<Attempt>,
) -> oneshot::Sender<()> {
    let (cancel_tx, cancel_rx) = oneshot::channel();
    let results = results.clone();
    tauri::async_runtime::spawn(async move {
        let outcome = HeartRateClient::connect_cancellable(selector, hr_config(), async {
            // Resolves on an explicit cancel *and* on the sender being dropped.
            let _ = cancel_rx.await;
        })
        .await;
        let _ = results.send(Attempt { generation, outcome }).await;
    });
    cancel_tx
}

/// Close the link and clear the published reading.
async fn shutdown(client: Option<HeartRateClient>, bpm_tx: &watch::Sender<Option<u8>>) {
    let _ = bpm_tx.send(None);
    if let Some(c) = client {
        c.shutdown().await;
    }
}

/// Turn a connect failure into something the rider can act on (FR-9.2).
fn connect_hint(e: &FtmsError) -> String {
    match e {
        FtmsError::NotFound(_) => "Could not find the heart rate monitor. A strap needs to be \
                                   worn to advertise; a watch needs Broadcast Heart Rate \
                                   switched on. Then try again."
            .to_string(),
        FtmsError::NoAdapter => {
            "No Bluetooth adapter. Check the radio is on and BlueZ is running.".to_string()
        }
        // A watch in broadcast mode accepts one central; a second connect gets
        // torn down mid-handshake and surfaces as a bare BLE error.
        FtmsError::Bluetooth(_) | FtmsError::MissingCharacteristic(_) => format!(
            "Heart rate monitor is busy: {e}. It accepts one connection at a time — close any \
             other app that is holding it, then try again."
        ),
        other => other.to_string(),
    }
}

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

    #[test]
    fn default_status_is_idle_and_empty() {
        let s = HeartRateStatus::default();
        assert_eq!(s.state, ConnectionState::Idle);
        assert!(!s.is_attached());
        assert_eq!(s.bpm, None);
    }

    #[test]
    fn attached_covers_every_live_state() {
        for state in [
            ConnectionState::Connecting,
            ConnectionState::Connected,
            ConnectionState::Reconnecting,
        ] {
            let s = HeartRateStatus { state: state.clone(), ..HeartRateStatus::default() };
            assert!(s.is_attached(), "{state:?}");
        }
        let lost = HeartRateStatus {
            state: ConnectionState::Lost { reason: "gone".into() },
            ..HeartRateStatus::default()
        };
        assert!(!lost.is_attached());
    }

    #[test]
    fn reconnect_is_bounded_so_the_rider_is_eventually_told() {
        // FR-1.11 — same reasoning as the trainer's budget.
        let cfg = hr_config();
        assert_eq!(cfg.backoff.max_attempts, Some(RECONNECT_ATTEMPTS));
    }

    #[test]
    fn a_missing_monitor_is_explained_not_just_reported() {
        let hint = connect_hint(&FtmsError::NotFound("any heart rate monitor".into()));
        assert!(hint.to_lowercase().contains("broadcast"), "FR-1.8: {hint}");
    }
}
