//! Recently-joined rooms (W7 P5) — a small, purely-local, cosmetic convenience
//! list. Each entry remembers a gathering you were in: its label, the canonical
//! share ticket (a door you can try to re-enter through), and when you last
//! joined it. It is *not* a presence or reachability primitive — rejoining is
//! best-effort and only succeeds while someone is still in the room and reachable
//! through the stored ticket. Reliability across moves is P6 (discovery) and the
//! member-issued ticket floor, not this list.
//!
//! De-duplication is keyed on the room's `topic_id` (the stable room identity),
//! so re-joining the same gathering refreshes one entry instead of stacking
//! duplicates, even as the host address and label change between members/sessions.

use crate::network::PeerSpeakTicket;
use serde::{Deserialize, Serialize};

/// How many recent rooms to keep. Oldest entries fall off past this.
pub const RECENTS_MAX: usize = 12;

/// One recently-joined room. Stored locally in `AppConfig`; never sent over the
/// wire. `ticket` is the canonical share ticket captured on join (carries the
/// topic + a member address + the label).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Recent {
    /// Cosmetic room label at join time (already sanitized upstream). May be empty
    /// for an unlabeled room — the UI shows a fallback then.
    pub name: String,
    /// The share ticket to attempt a rejoin through.
    pub ticket: String,
    /// Unix seconds when we last joined this room. Used for ordering + "x ago".
    pub joined_at: u64,
}

/// The de-dup key for a recent: the room's `topic_id` when the ticket parses,
/// else the raw ticket string (so an unparseable entry still de-dups against an
/// identical one rather than stacking). Pure.
fn dedup_key(ticket: &str) -> Result<[u8; 32], &str> {
    PeerSpeakTicket::topic_of(ticket).ok_or(ticket)
}

/// Whether two tickets refer to the same room (same `topic_id`, or — for
/// unparseable tickets — the same exact string).
fn same_room(a: &str, b: &str) -> bool {
    dedup_key(a) == dedup_key(b)
}

/// Record a just-joined room at the front of `list` (most-recent-first).
///
/// If the room (by `topic_id`) is already present, its entry is refreshed —
/// the newest ticket, label, and timestamp win — and moved to the front rather
/// than duplicated. The list is then capped to [`RECENTS_MAX`]. Pure: the caller
/// supplies `now` (unix seconds) and persists the list afterwards.
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
    list.retain(|r| !same_room(&r.ticket, &ticket));
    list.insert(
        0,
        Recent {
            name,
            ticket,
            joined_at: now,
        },
    );
    list.truncate(RECENTS_MAX);
}

/// Drop the recent whose ticket refers to the same room as `ticket` (the × in
/// the UI). A no-op if no entry matches. Pure.
pub fn remove_recent(list: &mut Vec<Recent>, ticket: &str) {
    list.retain(|r| !same_room(&r.ticket, ticket));
}

/// A short human label for how long ago `then` was, relative to `now` (both unix
/// seconds): "just now", "5m ago", "3h ago", "2d ago". Saturates at days. Pure;
/// `then > now` (clock skew) reads as "just now".
pub fn relative_time(now: u64, then: u64) -> String {
    let secs = now.saturating_sub(then);
    if secs < 60 {
        "just now".to_string()
    } else if secs < 3600 {
        format!("{}m ago", secs / 60)
    } else if secs < 86_400 {
        format!("{}h ago", secs / 3600)
    } else {
        format!("{}d ago", secs / 86_400)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::network::PeerSpeakTicket;
    use iroh::{EndpointAddr, SecretKey};

    /// Build a real, parseable ticket for a fresh room with the given label.
    fn ticket(name: &str, topic: [u8; 32]) -> String {
        let host = SecretKey::generate().public();
        PeerSpeakTicket {
            host_addr: EndpointAddr::from(host),
            topic_id: topic,
            name: name.into(),
        }
        .to_string()
    }

    #[test]
    fn push_prepends_and_orders_most_recent_first() {
        let mut list = Vec::new();
        push_recent(&mut list, "A".into(), ticket("A", [1; 32]), 100);
        push_recent(&mut list, "B".into(), ticket("B", [2; 32]), 200);
        assert_eq!(list.len(), 2);
        assert_eq!(list[0].name, "B");
        assert_eq!(list[1].name, "A");
    }

    #[test]
    fn rejoining_same_topic_refreshes_not_duplicates() {
        let mut list = Vec::new();
        // Same room (topic [9;32]) joined twice, even via a different member's
        // ticket and a renamed label, must collapse to one refreshed entry.
        push_recent(&mut list, "HangOut".into(), ticket("HangOut", [9; 32]), 100);
        push_recent(&mut list, "A".into(), ticket("A", [1; 32]), 150);
        let newer = ticket("HangOut v2", [9; 32]);
        push_recent(&mut list, "HangOut v2".into(), newer.clone(), 300);
        assert_eq!(list.len(), 2);
        // The refreshed room is now at the front with the new label + timestamp.
        assert_eq!(list[0].name, "HangOut v2");
        assert_eq!(list[0].ticket, newer);
        assert_eq!(list[0].joined_at, 300);
    }

    #[test]
    fn capped_at_recents_max_dropping_oldest() {
        let mut list = Vec::new();
        for i in 0..(RECENTS_MAX as u64 + 5) {
            let mut topic = [0u8; 32];
            topic[0] = i as u8;
            push_recent(&mut list, format!("R{i}"), ticket("r", topic), i);
        }
        assert_eq!(list.len(), RECENTS_MAX);
        // The newest is at the front; the oldest survivors fell off.
        assert_eq!(list[0].name, format!("R{}", RECENTS_MAX as u64 + 4));
    }

    #[test]
    fn remove_drops_matching_room_only() {
        let mut list = Vec::new();
        let keep = ticket("keep", [1; 32]);
        let drop = ticket("drop", [2; 32]);
        push_recent(&mut list, "keep".into(), keep.clone(), 100);
        push_recent(&mut list, "drop".into(), drop.clone(), 200);
        // Removing by a DIFFERENT member's ticket for the same room still matches.
        let drop_other_member = ticket("drop", [2; 32]);
        remove_recent(&mut list, &drop_other_member);
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].name, "keep");
        // Removing something not present is a no-op.
        remove_recent(&mut list, &ticket("nope", [7; 32]));
        assert_eq!(list.len(), 1);
    }

    #[test]
    fn unparseable_tickets_dedup_by_exact_string() {
        let mut list = Vec::new();
        push_recent(&mut list, "junk".into(), "not-a-ticket".into(), 10);
        push_recent(&mut list, "junk-again".into(), "not-a-ticket".into(), 20);
        // Same raw string → one entry, refreshed.
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].joined_at, 20);
        // A different unparseable string is a distinct entry.
        push_recent(&mut list, "other".into(), "other-junk".into(), 30);
        assert_eq!(list.len(), 2);
    }

    #[test]
    fn relative_time_buckets() {
        assert_eq!(relative_time(1000, 1000), "just now");
        assert_eq!(relative_time(1000, 970), "just now"); // < 60s
        assert_eq!(relative_time(1000, 700), "5m ago");
        assert_eq!(relative_time(100_000, 100_000 - 3 * 3600), "3h ago");
        assert_eq!(relative_time(1_000_000, 1_000_000 - 2 * 86_400), "2d ago");
        // Clock skew (then in the future) saturates to "just now", never panics.
        assert_eq!(relative_time(100, 500), "just now");
    }
}
