//! Friends presence/control plane — pure protocol + policy (W7 P4 core).
//!
//! This is the logic half of the "friends-only idle listener" from
//! `docs/contacts-plan.md`: while the app is open it answers presence pings from
//! *friends only* (no stranger surface, no DNS beacon) and, if we're currently in
//! a gathering, hands back a one-click-join member ticket. Here we define the
//! wire messages, the authorization gate, the user's presence posture, and the
//! defensive interpretation of replies — all pure and unit-tested.
//!
//! **Deliberately NOT here (deferred to a 2-machine session):** binding the live
//! control endpoint, its accept loop, and the outbound ping scheduler. Those are
//! the I/O edges; keeping them out means the security-critical decisions (who we
//! answer, what we trust from a peer) are testable in isolation.

use crate::friends::FriendStore;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};

/// The user's presence posture — how reachable they are to friends while idle.
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
/// with no presence beacon.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PresenceMode {
    /// Don't answer pings at all — appear offline to everyone, even friends.
    Invisible,
    /// Answer pings from friends only, from our saved address. No DNS beacon.
    /// The default.
    #[default]
    Normal,
    /// Like `Normal`, and additionally publish to discovery so friends can still
    /// find us after a network change (opt-in; the publish itself is wired with
    /// the live endpoint later). Asymmetric: only the mover needs this on.
    Discoverable,
}

impl PresenceMode {
    /// All postures, default first — the option list for the Settings/home picker.
    pub const ALL: [PresenceMode; 3] = [
        PresenceMode::Normal,
        PresenceMode::Invisible,
        PresenceMode::Discoverable,
    ];

    /// Whether this posture publishes to discovery (the only mode that does).
    pub fn publishes_to_discovery(self) -> bool {
        matches!(self, PresenceMode::Discoverable)
    }

    /// Whether this posture answers friend pings at all.
    pub fn answers_pings(self) -> bool {
        !matches!(self, PresenceMode::Invisible)
    }
}

impl std::fmt::Display for PresenceMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let label = match self {
            PresenceMode::Normal => "Normal — friends only",
            PresenceMode::Invisible => "Invisible — appear offline",
            PresenceMode::Discoverable => "Discoverable — findable after a move",
        };
        f.write_str(label)
    }
}

/// What a peer advertises about the gathering they're currently in. Both fields
/// are peer-supplied and therefore untrusted — see [`interpret_pong`], which
/// sanitizes the name and validates the ticket before surfacing them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomPresence {
    /// The cosmetic room label ("HangOut").
    pub name: String,
    /// A member-issued ticket the recipient can one-click join (points at the
    /// sender's own address — see [`crate::network::PeerSpeakTicket::restamp`]).
    pub ticket: String,
}

/// A message on the friends presence/control plane.
///
/// `#[serde(tag = "type")]` keeps the JSON self-describing and lets us add
/// variants without breaking older peers (an unknown tag fails to parse and is
/// dropped, rather than being misread as another variant).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlMsg {
    /// "Are you there?" — a presence probe.
    Ping,
    /// Reply to a ping: I'm online; `room` is `Some` when I'm in a gathering you
    /// could join.
    Pong { room: Option<RoomPresence> },
}

/// The authorization gate for the idle listener: whether to answer an inbound
/// ping from `from`. **Friends-only, and never while invisible.** This is the
/// whitelist that keeps the always-on-while-open endpoint from being a
/// stranger-facing spam/DoS surface — the single most security-critical decision
/// in the friends-first model, so it lives in one pure, tested function.
///
/// `from` must be the connection's *authenticated* remote id (`remote_id()`),
/// never a value carried in the payload.
pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMode) -> bool {
    mode.answers_pings() && friends.contains(from)
}

/// What we learned about a friend's reachability. `Online`/`InRoom` come from a
/// successful ping reply (see [`interpret_pong`]); `Offline` is produced by the
/// presence scheduler when a probe fails or the friend has no known address, so a
/// friend who drops or leaves is *actively* downgraded rather than left showing a
/// stale status. The UI also treats a missing entry as offline.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FriendPresence {
    /// Online, but not in a gathering we can join.
    Online,
    /// Online and in a joinable gathering (name already sanitized, ticket already
    /// validated as parseable).
    InRoom { name: String, ticket: String },
    /// Unreachable: the probe failed, or we have no address to probe yet.
    Offline,
}

/// Interpret a peer's reply defensively. `from` must be the connection's
/// authenticated remote id, not any value carried in the payload. Only a `Pong`
/// is a reply (a `Ping` is not, so it yields `None`). When the peer reports a
/// room, we **sanitize the peer-supplied name** and **only surface it as joinable
/// if the ticket actually parses** as a [`crate::network::PeerSpeakTicket`] and
/// points back at the replying friend. A garbage/redirect ticket downgrades the
/// friend to plain `Online` rather than offering a dead or attacker-controlled
/// Join button. (We still never auto-join; the user clicks.)
pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresence> {
    match msg {
        ControlMsg::Ping => None,
        ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
        ControlMsg::Pong { room: Some(r) } => {
            let Ok(ticket) = r.ticket.parse::<crate::network::PeerSpeakTicket>() else {
                // Online, but the advertised room is unusable — don't offer Join.
                return Some(FriendPresence::Online);
            };
            if ticket.host_addr.id != from {
                // Online, but the advertised room redirects away from the friend
                // who authenticated this Pong — don't offer a phishing Join.
                return Some(FriendPresence::Online);
            }
            Some(FriendPresence::InRoom {
                name: crate::sanitize::sanitize_name(&r.name),
                ticket: r.ticket.clone(),
            })
        }
    }
}

/// Map a single probe outcome to a definitive [`FriendPresence`], used by the
/// presence scheduler. `Some((reply, from))` is a received message from the
/// authenticated remote `from`; `None` means the probe failed (offline /
/// unreachable / refused). Anything that doesn't interpret as a real presence —
/// a probe error, or a non-`Pong` reply — becomes [`FriendPresence::Offline`], so
/// a friend who drops is actively downgraded instead of keeping a stale status.
/// Pure so the scheduler's downgrade behaviour is unit-testable without a network.
pub fn presence_from_probe(reply: Option<(&ControlMsg, EndpointId)>) -> FriendPresence {
    match reply {
        Some((msg, from)) => interpret_pong(msg, from).unwrap_or(FriendPresence::Offline),
        None => FriendPresence::Offline,
    }
}

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

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

    fn valid_ticket(for_id: EndpointId) -> String {
        PeerSpeakTicket {
            host_addr: EndpointAddr::from(for_id),
            topic_id: [9u8; 32],
            name: String::new(),
        }
        .to_string()
    }

    #[test]
    fn should_answer_is_friends_only_and_respects_mode() {
        let mut friends = FriendStore::default();
        let friend = id();
        let stranger = id();
        friends.add(friend, "Pal".into(), None);

        // Friend + a ping-answering mode -> answer.
        assert!(should_answer(&friend, &friends, PresenceMode::Normal));
        assert!(should_answer(&friend, &friends, PresenceMode::Discoverable));
        // Friend but invisible -> silent.
        assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
        // Stranger is NEVER answered, in any mode.
        assert!(!should_answer(&stranger, &friends, PresenceMode::Normal));
        assert!(!should_answer(
            &stranger,
            &friends,
            PresenceMode::Discoverable
        ));
        assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
    }

    #[test]
    fn presence_mode_flags() {
        assert!(PresenceMode::Discoverable.publishes_to_discovery());
        assert!(!PresenceMode::Normal.publishes_to_discovery());
        assert!(!PresenceMode::Invisible.publishes_to_discovery());
        assert!(PresenceMode::Normal.answers_pings());
        assert!(PresenceMode::Discoverable.answers_pings());
        assert!(!PresenceMode::Invisible.answers_pings());
        assert_eq!(PresenceMode::default(), PresenceMode::Normal);
    }

    #[test]
    fn control_msg_round_trips() {
        let cases = [
            ControlMsg::Ping,
            ControlMsg::Pong { room: None },
            ControlMsg::Pong {
                room: Some(RoomPresence {
                    name: "HangOut".into(),
                    ticket: "abc".into(),
                }),
            },
        ];
        for msg in cases {
            let bytes = serde_json::to_vec(&msg).unwrap();
            let back: ControlMsg = serde_json::from_slice(&bytes).unwrap();
            assert_eq!(back, msg);
        }
    }

    #[test]
    fn unknown_message_tag_is_rejected() {
        // Forward-compat: an unknown variant fails to parse (dropped, not misread).
        assert!(serde_json::from_str::<ControlMsg>(r#"{"type":"nonsense"}"#).is_err());
    }

    #[test]
    fn interpret_ping_is_not_a_reply() {
        assert_eq!(interpret_pong(&ControlMsg::Ping, id()), None);
    }

    #[test]
    fn interpret_pong_online_and_inroom() {
        let friend = id();
        // No room -> Online.
        assert_eq!(
            interpret_pong(&ControlMsg::Pong { room: None }, friend),
            Some(FriendPresence::Online)
        );
        // Valid ticket -> InRoom with a sanitized name.
        let t = valid_ticket(friend);
        let got = interpret_pong(
            &ControlMsg::Pong {
                room: Some(RoomPresence {
                    name: "HangOut".into(),
                    ticket: t.clone(),
                }),
            },
            friend,
        );
        assert_eq!(
            got,
            Some(FriendPresence::InRoom {
                name: "HangOut".into(),
                ticket: t
            })
        );
    }

    #[test]
    fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
        // A friend reporting a room with an unparseable ticket is treated as just
        // Online — no dead/hostile Join button is surfaced.
        let got = interpret_pong(
            &ControlMsg::Pong {
                room: Some(RoomPresence {
                    name: "Trap".into(),
                    ticket: "not-a-ticket".into(),
                }),
            },
            id(),
        );
        assert_eq!(got, Some(FriendPresence::Online));
    }

    #[test]
    fn interpret_pong_rejects_ticket_for_a_different_host() {
        let friend = id();
        let attacker = id();
        let t = valid_ticket(attacker);
        let got = interpret_pong(
            &ControlMsg::Pong {
                room: Some(RoomPresence {
                    name: "Redirect".into(),
                    ticket: t,
                }),
            },
            friend,
        );
        assert_eq!(got, Some(FriendPresence::Online));
    }

    #[test]
    fn presence_from_probe_maps_outcomes_to_definitive_status() {
        let friend = id();
        // A failed probe (no reply) is an explicit downgrade to Offline, so the UI
        // clears a friend who has dropped instead of keeping a stale status.
        assert_eq!(presence_from_probe(None), FriendPresence::Offline);
        // A successful Pong with no room is Online.
        assert_eq!(
            presence_from_probe(Some((&ControlMsg::Pong { room: None }, friend))),
            FriendPresence::Online
        );
        // A successful Pong advertising the friend's own room is InRoom.
        let t = valid_ticket(friend);
        assert_eq!(
            presence_from_probe(Some((
                &ControlMsg::Pong {
                    room: Some(RoomPresence {
                        name: "Den".into(),
                        ticket: t.clone()
                    })
                },
                friend,
            ))),
            FriendPresence::InRoom {
                name: "Den".into(),
                ticket: t
            }
        );
        // A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
        assert_eq!(
            presence_from_probe(Some((&ControlMsg::Ping, friend))),
            FriendPresence::Offline
        );
    }

    #[test]
    fn interpret_pong_sanitizes_a_hostile_room_name() {
        // Control/bidi characters in a peer-supplied name are stripped.
        let friend = id();
        let t = valid_ticket(friend);
        let got = interpret_pong(
            &ControlMsg::Pong {
                room: Some(RoomPresence {
                    name: "Hang\u{202e}Out\u{0007}".into(),
                    ticket: t.clone(),
                }),
            },
            friend,
        );
        match got {
            Some(FriendPresence::InRoom { name, .. }) => {
                assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
                assert!(!name.contains('\u{0007}'), "control char must be stripped");
            }
            other => panic!("expected InRoom, got {other:?}"),
        }
    }
}
