//! BLE discovery (FR-1.1, FR-1.2).

use std::collections::HashMap;
use std::time::Duration;

use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
use uuid::Uuid;

use crate::error::FtmsError;
use crate::{uuids, zwift};

/// Zwift's custom service UUID, used to recognise Click pods during a scan
/// (FR-1.2). Re-exported from [`crate::zwift`], which owns the protocol.
pub use crate::zwift::SERVICE as ZWIFT_SERVICE;

/// Zwift's Bluetooth SIG manufacturer ID (2378).
pub use crate::zwift::MANUFACTURER_ID as ZWIFT_MANUFACTURER_ID;

/// A peripheral seen during a scan.
#[derive(Debug, Clone)]
pub struct DiscoveredDevice {
    pub id: PeripheralId,
    /// Canonical lowercase MAC-style address string.
    pub address: String,
    pub name: Option<String>,
    pub rssi: Option<i16>,
    pub tx_power: Option<i16>,
    pub services: Vec<Uuid>,
    pub manufacturer_data: HashMap<u16, Vec<u8>>,
    pub service_data: HashMap<Uuid, Vec<u8>>,
}

impl DiscoveredDevice {
    /// True when the peripheral advertises the FTMS service (FR-1.2).
    ///
    /// Note that advertising is not mandatory: a trainer may expose FTMS
    /// without listing it in its advertisement. `probe scan` therefore lists
    /// everything, and connecting by address always works.
    pub fn is_fitness_machine(&self) -> bool {
        self.services.contains(&uuids::FITNESS_MACHINE_SERVICE)
    }

    /// True when the peripheral looks like a Zwift controller.
    ///
    /// Note that the Van Rysel D100 also advertises Zwift's custom service, so
    /// this is "speaks the Zwift protocol", not "is a Click".
    pub fn is_zwift_device(&self) -> bool {
        self.services.contains(&ZWIFT_SERVICE)
            || self.manufacturer_data.contains_key(&ZWIFT_MANUFACTURER_ID)
    }

    /// Which Zwift device this is, from the type byte in its manufacturer data
    /// (§2.3.1). `None` when it advertises no Zwift manufacturer data at all —
    /// which is the case for a trainer that merely exposes the service.
    pub fn zwift_kind(&self) -> Option<zwift::DeviceKind> {
        self.manufacturer_data
            .get(&ZWIFT_MANUFACTURER_ID)
            .and_then(|d| zwift::DeviceKind::from_manufacturer_data(d))
    }

    /// True when this is a controller pod the app can drive — a Click, not a
    /// trainer that merely speaks the Zwift protocol. The D100 advertises the
    /// custom service with no manufacturer data, which is exactly the case this
    /// separates out.
    pub fn is_click_pod(&self) -> bool {
        self.zwift_kind().is_some_and(|k| k.is_click())
    }

    /// Which pod of a pair this is, where the advertisement says so (FR-1.4).
    pub fn pod_id(&self) -> Option<zwift::PodId> {
        self.zwift_kind().and_then(|k| k.pod_id())
    }

    /// Best-effort human label.
    pub fn label(&self) -> String {
        match &self.name {
            Some(n) if !n.is_empty() => n.clone(),
            _ => "(no name)".to_string(),
        }
    }
}

/// Get the first Bluetooth adapter on the system.
pub async fn default_adapter() -> Result<Adapter, FtmsError> {
    let manager = Manager::new().await?;
    manager
        .adapters()
        .await?
        .into_iter()
        .next()
        .ok_or(FtmsError::NoAdapter)
}

/// What to scan for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanKind {
    /// Every peripheral the adapter reports.
    All,
    /// Only peripherals advertising the FTMS service.
    FitnessMachines,
}

impl ScanKind {
    fn filter(self) -> ScanFilter {
        match self {
            // An empty filter means "everything". Some backends require a
            // filter for privacy reasons; on Linux/BlueZ an empty one is fine.
            ScanKind::All => ScanFilter::default(),
            ScanKind::FitnessMachines => ScanFilter {
                services: vec![uuids::FITNESS_MACHINE_SERVICE],
            },
        }
    }
}

/// Scan for `duration` and return everything seen.
///
/// Devices may be asleep (A-4) — a trainer often does not advertise until it is
/// pedalled. An empty result means "nothing was advertising", not "no such
/// device exists"; FR-1.8 requires the UI to say so.
pub async fn scan(
    adapter: &Adapter,
    duration: Duration,
    kind: ScanKind,
) -> Result<Vec<DiscoveredDevice>, FtmsError> {
    adapter.start_scan(kind.filter()).await?;
    tokio::time::sleep(duration).await;
    let peripherals = adapter.peripherals().await?;
    // Stopping the scan is best effort; a failure here must not lose results.
    if let Err(e) = adapter.stop_scan().await {
        tracing::debug!(error = %e, "stop_scan failed");
    }

    let mut out = Vec::with_capacity(peripherals.len());
    for p in peripherals {
        if let Some(d) = describe(&p).await {
            if kind == ScanKind::FitnessMachines && !d.is_fitness_machine() {
                // Some backends ignore the service filter; enforce it here too.
                continue;
            }
            out.push(d);
        }
    }
    out.sort_by(|a, b| b.rssi.unwrap_or(i16::MIN).cmp(&a.rssi.unwrap_or(i16::MIN)));
    Ok(out)
}

/// Convenience wrapper: scan the default adapter for trainers.
pub async fn scan_trainers(duration: Duration) -> Result<Vec<DiscoveredDevice>, FtmsError> {
    let adapter = default_adapter().await?;
    scan(&adapter, duration, ScanKind::FitnessMachines).await
}

/// Snapshot a peripheral's advertisement data.
pub async fn describe(p: &Peripheral) -> Option<DiscoveredDevice> {
    let props = p.properties().await.ok().flatten()?;
    Some(DiscoveredDevice {
        id: p.id(),
        address: props.address.to_string().to_lowercase(),
        name: props.local_name.clone(),
        rssi: props.rssi,
        tx_power: props.tx_power_level,
        services: props.services.clone(),
        manufacturer_data: props.manufacturer_data.clone(),
        service_data: props.service_data.clone(),
    })
}

/// How to pick a trainer out of a scan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrainerSelector {
    /// The first peripheral advertising FTMS. Fine when only one trainer is in
    /// the room; ambiguous otherwise.
    Any,
    /// Match on address, case-insensitively (`AA:BB:CC:DD:EE:FF`, or the
    /// platform's opaque identifier on macOS).
    Address(String),
    /// Match when the advertised local name contains this, case-insensitively.
    NameContains(String),
}

impl TrainerSelector {
    /// Does this peripheral match the selector?
    pub fn matches(&self, d: &DiscoveredDevice) -> bool {
        self.matches_parts(
            &d.address,
            d.name.as_deref(),
            d.is_fitness_machine(),
            &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,
        name: Option<&str>,
        is_fitness_machine: bool,
        id_debug: &str,
    ) -> bool {
        match self {
            TrainerSelector::Any => is_fitness_machine,
            TrainerSelector::Address(a) => {
                address.eq_ignore_ascii_case(a)
                    || id_debug.to_lowercase().contains(&a.to_lowercase())
            }
            TrainerSelector::NameContains(n) => name
                .map(|name| name.to_lowercase().contains(&n.to_lowercase()))
                .unwrap_or(false),
        }
    }

    /// Human-readable description, for error messages.
    pub fn describe(&self) -> String {
        match self {
            TrainerSelector::Any => "any FTMS trainer".to_string(),
            TrainerSelector::Address(a) => format!("address {a}"),
            TrainerSelector::NameContains(n) => format!("name containing {n:?}"),
        }
    }

    /// A selector by address needs an unfiltered scan, because a peripheral is
    /// not obliged to advertise the FTMS service.
    fn scan_kind(&self) -> ScanKind {
        match self {
            TrainerSelector::Any => ScanKind::FitnessMachines,
            _ => ScanKind::All,
        }
    }
}

/// Scan until a peripheral matching `selector` appears, or `timeout` elapses.
pub async fn find_peripheral(
    adapter: &Adapter,
    selector: &TrainerSelector,
    timeout: Duration,
) -> Result<Peripheral, FtmsError> {
    find_matching(
        adapter,
        selector.scan_kind(),
        timeout,
        &selector.describe(),
        |d| selector.matches(d),
    )
    .await
}

/// Scan until a peripheral satisfying `matches` appears, or `timeout` elapses.
///
/// The predicate form exists because a controller is not selected the way a
/// trainer is: a Click pod is picked out by the type byte in its manufacturer
/// data (§2.3.1), which no `TrainerSelector` variant can express. `what`
/// describes the search well enough to read in an error message.
pub async fn find_matching(
    adapter: &Adapter,
    kind: ScanKind,
    timeout: Duration,
    what: &str,
    matches: impl Fn(&DiscoveredDevice) -> bool,
) -> Result<Peripheral, FtmsError> {
    adapter.start_scan(kind.filter()).await?;

    let deadline = tokio::time::Instant::now() + timeout;
    let poll = Duration::from_millis(400);
    let mut found: Option<Peripheral> = None;

    'search: loop {
        for p in adapter.peripherals().await?.into_iter() {
            if let Some(d) = describe(&p).await {
                if matches(&d) {
                    tracing::info!(
                        address = %d.address,
                        name = d.label(),
                        rssi = ?d.rssi,
                        %what,
                        "matched peripheral"
                    );
                    found = Some(p);
                    break 'search;
                }
            }
        }
        if tokio::time::Instant::now() >= deadline {
            break 'search;
        }
        tokio::time::sleep(poll).await;
    }

    if let Err(e) = adapter.stop_scan().await {
        tracing::debug!(error = %e, "stop_scan failed");
    }

    found.ok_or_else(|| FtmsError::NotFound(what.to_string()))
}

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

    #[test]
    fn zwift_service_uuid_matches_the_spec() {
        assert_eq!(
            ZWIFT_SERVICE.to_string(),
            "00000001-19ca-4651-86e5-fa29dcdd09d1"
        );
        assert_eq!(ZWIFT_MANUFACTURER_ID, 2378);
    }

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

    #[test]
    fn selector_address_is_case_insensitive_and_ignores_advertised_services() {
        let s = TrainerSelector::Address("AA:BB:CC:DD:EE:FF".into());
        assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", None, false, ""));
        assert!(!s.matches_parts("11:22:33:44:55:66", None, true, ""));
    }

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

    #[test]
    fn selector_name_is_a_case_insensitive_substring() {
        let s = TrainerSelector::NameContains("d100".into());
        assert!(s.matches_parts("", Some("VAN RYSEL D100 4321"), false, ""));
        assert!(!s.matches_parts("", Some("KICKR CORE"), true, ""));
        assert!(!s.matches_parts("", None, true, ""));
    }

    #[test]
    fn selector_scan_kind_widens_for_address_and_name() {
        assert_eq!(TrainerSelector::Any.scan_kind(), ScanKind::FitnessMachines);
        assert_eq!(
            TrainerSelector::Address("x".into()).scan_kind(),
            ScanKind::All
        );
        assert_eq!(
            TrainerSelector::NameContains("x".into()).scan_kind(),
            ScanKind::All
        );
    }

    #[test]
    fn scan_filter_for_fitness_machines_carries_the_ftms_uuid() {
        assert_eq!(
            ScanKind::FitnessMachines.filter().services,
            vec![uuids::FITNESS_MACHINE_SERVICE]
        );
        assert!(ScanKind::All.filter().services.is_empty());
    }
}
