//! Devices the rider has paired before, remembered across launches (FR-1.5).
//!
//! Until now "remembered" was a `HashSet` inside [`crate::devices::DeviceRegistry`],
//! which meant it lasted exactly as long as the process. Every launch started
//! from nothing: find the trainer, press Connect, find the strap, press
//! Connect, and only then ride. This is that set written down.
//!
//! ```text
//!   app_data_dir()/devices.json
//!   { "version": 1,
//!     "devices":   [ { address, name, kind }, … ],
//!     "forgotten": [ address, … ] }
//! ```
//!
//! Two lists rather than one, because *forgotten* is not merely "absent".
//! Absent means never seen; forgotten means the rider looked at this device and
//! said no, and auto-connect has to keep honouring that on the next launch too.
//!
//! The file is small and written only when something actually changes — a pair,
//! an unpair, a name learned — so this never lands in the ride loop's path. It
//! is also *advisory*: a corrupt or unreadable file costs the rider their
//! auto-connect, never their ride, so every failure here is logged and
//! swallowed rather than propagated.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};

use crate::devices::DeviceKind;

/// Bumped only if the shape changes incompatibly. An older file with a version
/// we do not know is discarded rather than guessed at.
const VERSION: u32 = 1;
const FILE: &str = "devices.json";

/// One device the rider has paired with.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnownDevice {
    /// As the adapter reports it. Matching is case-insensitive — see [`key`] —
    /// but what is written down is what we were told.
    pub address: String,
    pub name: Option<String>,
    pub kind: DeviceKind,
}

/// The file on disk. Kept separate from the in-memory form so the indexes below
/// are never serialised.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct Stored {
    version: u32,
    devices: Vec<KnownDevice>,
    forgotten: Vec<String>,
}

/// Address as we match on it. BlueZ hands back `F4:C4:59:…` and Android
/// `f4:c4:59:…` for the same peripheral, and a pairing that survives a launch
/// but not a platform is not much of a pairing.
fn key(address: &str) -> String {
    address.trim().to_ascii_uppercase()
}

/// Everything the app remembers about the hardware in the room, plus where to
/// write it.
#[derive(Debug, Default)]
pub struct KnownDevices {
    devices: BTreeMap<String, KnownDevice>,
    forgotten: BTreeSet<String>,
    /// `None` before [`KnownDevices::load`] — `AppState::new` runs before there
    /// is an `AppHandle` to ask for a data directory, so the registry spends
    /// the first moments of the process with an in-memory-only store.
    path: Option<PathBuf>,
}

impl KnownDevices {
    /// Read the file, or start empty if it is missing, unreadable or from a
    /// version we do not understand.
    pub fn load(path: PathBuf) -> Self {
        let mut out = Self {
            path: Some(path.clone()),
            ..Self::default()
        };
        let text = match std::fs::read_to_string(&path) {
            Ok(t) => t,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
            Err(e) => {
                tracing::warn!(path = %path.display(), error = %e, "could not read remembered devices");
                return out;
            }
        };
        let stored: Stored = match serde_json::from_str(&text) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(path = %path.display(), error = %e, "remembered devices are unreadable; starting fresh");
                return out;
            }
        };
        if stored.version != VERSION {
            tracing::warn!(
                found = stored.version,
                expected = VERSION,
                "remembered devices are from another version; starting fresh"
            );
            return out;
        }
        for device in stored.devices {
            out.devices.insert(key(&device.address), device);
        }
        out.forgotten = stored.forgotten.iter().map(|a| key(a)).collect();
        out
    }

    pub fn len(&self) -> usize {
        self.devices.len()
    }

    pub fn is_empty(&self) -> bool {
        self.devices.is_empty()
    }

    /// Has this device been paired with before?
    pub fn contains(&self, address: &str) -> bool {
        self.devices.contains_key(&key(address))
    }

    /// Did the rider say no to this device?
    pub fn is_forgotten(&self, address: &str) -> bool {
        self.forgotten.contains(&key(address))
    }

    /// The remembered device of this kind, if there is one. Used to prefer
    /// *our* Click over the identically-named one in the next room.
    pub fn first_of(&self, kind: DeviceKind) -> Option<&KnownDevice> {
        self.devices.values().find(|d| d.kind == kind)
    }

    pub fn any_of_kind(&self, kind: DeviceKind) -> bool {
        self.first_of(kind).is_some()
    }

    /// Record a pairing. No-op — and no write — when nothing changed, which is
    /// the common case: this is called from the device poll, four times a
    /// second.
    pub fn remember(&mut self, address: &str, name: Option<&str>, kind: DeviceKind) {
        // Nothing useful to auto-connect to, and a list full of every anonymous
        // peripheral in the building helps nobody.
        if kind == DeviceKind::Unknown || address.trim().is_empty() {
            return;
        }
        let id = key(address);
        let entry = KnownDevice {
            address: address.to_string(),
            name: name.map(str::to_owned).filter(|n| !n.trim().is_empty()),
            kind,
        };
        let unchanged = self.devices.get(&id) == Some(&entry);
        let was_forgotten = self.forgotten.remove(&id);
        if unchanged && !was_forgotten {
            return;
        }
        tracing::info!(address, name, ?kind, "remembering device");
        self.devices.insert(id, entry);
        self.save();
    }

    /// The rider said no. Both halves matter: drop the pairing *and* record the
    /// refusal, so the next launch does not helpfully connect it again.
    pub fn forget(&mut self, address: &str) {
        let id = key(address);
        let removed = self.devices.remove(&id).is_some();
        let added = self.forgotten.insert(id);
        if removed || added {
            tracing::info!(address, "forgetting device");
            self.save();
        }
    }

    /// An explicit connect outranks an earlier refusal.
    pub fn unforget(&mut self, address: &str) {
        if self.forgotten.remove(&key(address)) {
            self.save();
        }
    }

    /// Write the file, atomically: a half-written `devices.json` would be
    /// discarded whole on the next launch, and losing the pairings because the
    /// power went out mid-`write` is exactly the failure this module exists to
    /// prevent.
    fn save(&self) {
        let Some(path) = &self.path else {
            return;
        };
        let stored = Stored {
            version: VERSION,
            devices: self.devices.values().cloned().collect(),
            forgotten: self.forgotten.iter().cloned().collect(),
        };
        if let Err(e) = write_atomic(path, &stored) {
            // Advisory, not fatal: the rider loses auto-connect on the next
            // launch, never this ride.
            tracing::warn!(path = %path.display(), error = %e, "could not save remembered devices");
        }
    }
}

fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
    let text = serde_json::to_string_pretty(stored)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir)?;
    }
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, text)?;
    std::fs::rename(&tmp, path)
}

/// Where the remembered devices live: beside the recorded rides, in the app's
/// own data directory.
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
    let dir = app
        .path()
        .app_data_dir()
        .map_err(|e| format!("no app data directory: {e}"))?;
    std::fs::create_dir_all(&dir)
        .map_err(|e| format!("could not create {}: {e}", dir.display()))?;
    Ok(dir.join(FILE))
}

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

    fn temp() -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "bikecontrol-known-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        std::fs::create_dir_all(&dir).unwrap();
        dir.join(FILE)
    }

    #[test]
    fn a_pairing_survives_a_reload() {
        let path = temp();
        let _ = std::fs::remove_file(&path);
        let mut known = KnownDevices::load(path.clone());
        known.remember(
            "F4:C4:59:03:A1:8E",
            Some("Zwift Click"),
            DeviceKind::ClickMinus,
        );

        let again = KnownDevices::load(path);
        assert!(again.contains("F4:C4:59:03:A1:8E"));
        // The same peripheral, as Android spells it.
        assert!(again.contains("f4:c4:59:03:a1:8e"));
        assert_eq!(
            again
                .first_of(DeviceKind::ClickMinus)
                .unwrap()
                .name
                .as_deref(),
            Some("Zwift Click")
        );
    }

    #[test]
    fn forgetting_outlives_the_process_too() {
        // The whole point: "no" has to be remembered as firmly as "yes", or the
        // next launch connects the neighbour's trainer again.
        let path = temp().with_extension("forget.json");
        let _ = std::fs::remove_file(&path);
        let mut known = KnownDevices::load(path.clone());
        known.remember("AA:BB:CC:DD:EE:FF", Some("D100"), DeviceKind::Trainer);
        known.forget("aa:bb:cc:dd:ee:ff");

        let again = KnownDevices::load(path);
        assert!(!again.contains("AA:BB:CC:DD:EE:FF"));
        assert!(again.is_forgotten("AA:BB:CC:DD:EE:FF"));
        assert!(!again.any_of_kind(DeviceKind::Trainer));
    }

    #[test]
    fn connecting_again_undoes_a_refusal() {
        let mut known = KnownDevices::default();
        known.forget("AA:BB:CC:DD:EE:FF");
        assert!(known.is_forgotten("AA:BB:CC:DD:EE:FF"));
        known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Trainer);
        assert!(!known.is_forgotten("AA:BB:CC:DD:EE:FF"));
        assert!(known.contains("AA:BB:CC:DD:EE:FF"));
    }

    #[test]
    fn an_unidentified_peripheral_is_not_worth_remembering() {
        let mut known = KnownDevices::default();
        known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Unknown);
        assert!(known.is_empty());
    }

    #[test]
    fn a_corrupt_file_costs_the_pairings_and_nothing_else() {
        let path = temp().with_extension("corrupt.json");
        std::fs::write(&path, b"{ this is not json").unwrap();
        let known = KnownDevices::load(path);
        assert!(known.is_empty());
    }
}
