//! Persistent friends list at `~/.config/peerspeak/friends.json` (W7 P2).
//!
//! In the friends-first contacts model (`docs/contacts-plan.md`) the friends
//! list is the durable anchor: rooms are ephemeral cosmetic labels, but a friend
//! is a stable [`EndpointId`] you keep across sessions. This module is the pure,
//! local store — no networking. Reachability (presence pings, the idle listener,
//! discovery) is built on top in later phases; here we just remember *who* your
//! friends are, a locally-editable display name, and their last-known address so
//! a future reconnect has somewhere to dial.
//!
//! Stored as JSON (peerspeak uses `serde_json` everywhere — no `toml` dep), in
//! its own file rather than a section of `config.json` so a config reset can't
//! drop the list, mirroring the separate `identity.key`.
//!
//! Design: friends are added **explicitly** (meeting someone in a room does NOT
//! auto-friend them). Once someone is a friend, [`FriendStore::note_seen`]
//! refreshes their saved address whenever you connect — the silent address
//! auto-heal — without ever clobbering a name you set locally.

use anyhow::{Context, Result};
use iroh::{EndpointAddr, EndpointId};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

/// One saved friend.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Friend {
    /// Stable node id — the durable identity we key everything on.
    pub id: EndpointId,
    /// Display name — seeded from the name the peer reported, locally editable.
    pub name: String,
    /// Last address we successfully saw them at, refreshed on every connection
    /// (the auto-heal). `None` until we've connected at least once since adding.
    #[serde(default)]
    pub last_addr: Option<EndpointAddr>,
}

/// The persisted friends list.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FriendStore {
    #[serde(default)]
    pub friends: Vec<Friend>,
}

impl FriendStore {
    /// Look up a friend by id.
    pub fn get(&self, id: &EndpointId) -> Option<&Friend> {
        self.friends.iter().find(|f| &f.id == id)
    }

    /// Whether `id` is a saved friend.
    pub fn contains(&self, id: &EndpointId) -> bool {
        self.friends.iter().any(|f| &f.id == id)
    }

    /// All friends, in stored order.
    pub fn list(&self) -> &[Friend] {
        &self.friends
    }

    /// Explicitly add a friend. Returns `false` if they were already saved (in
    /// which case nothing changes — an existing local name/addr is preserved).
    /// `addr` seeds `last_addr` if known at add time (e.g. you met them in a room).
    pub fn add(&mut self, id: EndpointId, name: String, addr: Option<EndpointAddr>) -> bool {
        if self.contains(&id) {
            return false;
        }
        self.friends.push(Friend {
            id,
            name,
            last_addr: addr,
        });
        true
    }

    /// Remove a friend. Returns `true` if one was removed.
    pub fn remove(&mut self, id: &EndpointId) -> bool {
        let before = self.friends.len();
        self.friends.retain(|f| &f.id != id);
        self.friends.len() != before
    }

    /// Rename a friend locally. Returns `false` if `id` isn't a saved friend.
    pub fn rename(&mut self, id: &EndpointId, new_name: String) -> bool {
        match self.friends.iter_mut().find(|f| &f.id == id) {
            Some(f) => {
                f.name = new_name;
                true
            }
            None => false,
        }
    }

    /// Auto-heal hook: if `id` is a saved friend, refresh their `last_addr` to the
    /// address we just connected to. No-op (returns `false`) for non-friends — we
    /// never auto-add. Never touches the locally-set name. Returns `true` only
    /// when the stored address actually changed (so a caller can skip a needless
    /// disk write on an unchanged reconnect).
    pub fn note_seen(&mut self, id: &EndpointId, addr: EndpointAddr) -> bool {
        match self.friends.iter_mut().find(|f| &f.id == id) {
            Some(f) if f.last_addr.as_ref() != Some(&addr) => {
                f.last_addr = Some(addr);
                true
            }
            _ => false,
        }
    }
}

/// Returns `~/.config/peerspeak/friends.json` (or the XDG equivalent). Shares the
/// config directory with [`crate::config`]; the parent is created on save.
pub fn friends_path() -> Option<PathBuf> {
    dirs::config_dir().map(|mut p| {
        p.push("peerspeak");
        p.push("friends.json");
        p
    })
}

/// Load the store, or a default (empty) one if the file doesn't exist yet. A
/// *parse* error bubbles up so a hand-edit being debugged isn't silently
/// overwritten with an empty list.
pub fn load() -> Result<FriendStore> {
    let path =
        friends_path().context("could not determine a config directory for the friends list")?;
    load_at(&path)
}

/// Save the store. Atomic via tempfile-in-same-dir + rename.
pub fn save(store: &FriendStore) -> Result<()> {
    let path =
        friends_path().context("could not determine a config directory for the friends list")?;
    save_at(&path, store)
}

/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
fn load_at(path: &Path) -> Result<FriendStore> {
    match fs::read_to_string(path) {
        Ok(s) => {
            serde_json::from_str(&s).with_context(|| format!("failed to parse {}", path.display()))
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
        Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
    }
}

/// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then
/// rename, so a crash mid-write can't leave a truncated list.
fn save_at(path: &Path, store: &FriendStore) -> Result<()> {
    let parent = path
        .parent()
        .context("friends path has no parent directory")?;
    fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
    let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?;
    let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id()));
    fs::write(&tmp, json.as_bytes())
        .with_context(|| format!("failed to write {}", tmp.display()))?;
    fs::rename(&tmp, path)
        .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
    Ok(())
}

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

    fn id() -> EndpointId {
        SecretKey::generate().public()
    }

    fn addr_for(id: EndpointId) -> EndpointAddr {
        EndpointAddr::from(id)
    }

    #[test]
    fn add_is_idempotent_and_preserves_existing() {
        let mut s = FriendStore::default();
        let a = id();
        assert!(s.add(a, "Alice".into(), None));
        // Second add returns false and does NOT overwrite the existing entry.
        assert!(!s.add(a, "DIFFERENT".into(), Some(addr_for(a))));
        assert_eq!(s.get(&a).unwrap().name, "Alice");
        assert_eq!(s.get(&a).unwrap().last_addr, None);
        assert_eq!(s.list().len(), 1);
    }

    #[test]
    fn remove_and_contains() {
        let mut s = FriendStore::default();
        let a = id();
        s.add(a, "Alice".into(), None);
        assert!(s.contains(&a));
        assert!(s.remove(&a));
        assert!(!s.contains(&a));
        assert!(!s.remove(&a)); // already gone
    }

    #[test]
    fn rename_only_existing() {
        let mut s = FriendStore::default();
        let a = id();
        assert!(!s.rename(&a, "Nope".into())); // not a friend yet
        s.add(a, "Alice".into(), None);
        assert!(s.rename(&a, "Al".into()));
        assert_eq!(s.get(&a).unwrap().name, "Al");
    }

    #[test]
    fn note_seen_heals_addr_for_friends_only_and_keeps_name() {
        let mut s = FriendStore::default();
        let a = id();
        let stranger = id();
        s.add(a, "Alice".into(), None);
        // First sighting sets the address and reports a change.
        let addr1 = addr_for(a);
        assert!(s.note_seen(&a, addr1.clone()));
        assert_eq!(s.get(&a).unwrap().last_addr, Some(addr1.clone()));
        // Local rename, then a repeat sighting at the same addr: no change, name kept.
        s.rename(&a, "BestFriend".into());
        assert!(!s.note_seen(&a, addr1));
        assert_eq!(s.get(&a).unwrap().name, "BestFriend");
        // A stranger is never auto-added.
        assert!(!s.note_seen(&stranger, addr_for(stranger)));
        assert!(!s.contains(&stranger));
    }

    /// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
    fn temp_path(tag: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!(
            "peerspeak-friendstest-{}-{}",
            std::process::id(),
            tag
        ));
        p.push("friends.json");
        p
    }

    #[test]
    fn save_then_load_round_trips() {
        let path = temp_path("roundtrip");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        let mut s = FriendStore::default();
        let a = id();
        s.add(a, "Alice".into(), Some(addr_for(a)));
        s.add(id(), "Bob".into(), None);
        save_at(&path, &s).unwrap();
        let loaded = load_at(&path).unwrap();
        assert_eq!(loaded, s);
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }

    #[test]
    fn missing_file_loads_empty() {
        let path = temp_path("missing");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        let loaded = load_at(&path).unwrap();
        assert!(loaded.friends.is_empty());
    }

    #[test]
    fn malformed_file_is_an_error_not_silent_loss() {
        let path = temp_path("malformed");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(&path, "{ not valid json").unwrap();
        assert!(load_at(&path).is_err());
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }
}
