use async_trait::async_trait;
use bytes::Bytes;
use iroh::{EndpointAddr, EndpointId};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;
use tokio::sync::mpsc::Receiver;

#[derive(Error, Debug)]
pub enum NetError {
    #[error("Failed to initialize network: {0}")]
    Init(String),
    #[error("Failed to connect/dial peer: {0}")]
    Connection(String),
    #[error("Gossip swarm error: {0}")]
    Gossip(String),
    #[error("Serialization / Deserialization error: {0}")]
    Serde(String),
    #[error("Invalid ticket: {0}")]
    InvalidTicket(String),
    #[error("Other network error: {0}")]
    Other(String),
}

/// A peer's currently-broadcast music track + playback timeline (W22). Rides
/// gossip presence so listeners can tune in, follow track changes, and keep in
/// sync. Untrusted like `name`/`game`: the `name` is sanitized and `size` is
/// cap-checked at gossip ingest. Bytes never ride gossip — they are fetched
/// point-to-point over the files plane by `id`, exactly like a chat attachment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MusicPresence {
    /// Files-plane handle to fetch this track's bytes (minted per track by the DJ).
    pub id: crate::files::AttachmentId,
    /// Sanitized display name (track filename). Untrusted; cleaned at ingest.
    pub name: String,
    /// Byte length, bounds the listener's fetch. Must be `<= MAX_ATTACHMENT_BYTES`.
    pub size: u64,
    /// True while the DJ has the track paused.
    pub paused: bool,
    /// Wall-clock ms (UNIX epoch) of the timeline anchor. While playing, the true
    /// playhead is `position_ms + (now_ms - anchor_ms)`; while paused it is
    /// frozen at `position_ms`. Re-stamped on every play/pause/seek.
    pub anchor_ms: u64,
    /// Playhead position (ms) at `anchor_ms`.
    pub position_ms: u64,
    /// Files-plane handle for the DJ's NEXT track, so listeners can prefetch it
    /// for a gapless skip. `None` when there is no distinct next track (single
    /// item playlist) or the DJ isn't ready. Equals a future `id` once that
    /// track plays.
    #[serde(default)]
    pub next_id: Option<crate::files::AttachmentId>,
    /// Byte length of the next track; bounds the prefetch. Cap-checked at ingest.
    #[serde(default)]
    pub next_size: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PeerState {
    pub name: String,
    pub is_muted: bool,
    pub addr: iroh::EndpointAddr,
    /// When this peer is sharing their screen, the pixelpass relay ticket a
    /// viewer needs to watch it; `None` when not sharing. Riding presence means
    /// the existing gossip re-announce delivers it to late joiners for free, and
    /// a `PeerUpdated` fires automatically on share start/stop. Defaulted so
    /// older configs / peers that predate the field still deserialize.
    #[serde(default)]
    pub sharing: Option<String>,
    /// This peer's chosen avatar (W4). Rides presence so it reaches everyone
    /// (incl. late joiners) with no server, like `sharing`. Defaulted so older
    /// peers/configs that predate the field still deserialize (→ monogram).
    #[serde(default)]
    pub avatar: crate::avatar::Avatar,
    /// The game this peer is currently playing, as a display string only (shown as
    /// `Playing <name>` next to their avatar). Opt-in and **untrusted** like
    /// `name`: sanitized + length-capped at the gossip ingest boundary. `None` when
    /// the peer isn't sharing a game (feature off / nothing detected). Only the
    /// display string rides the wire — never the appid or detection source, to
    /// avoid fingerprinting and coupling the protocol to detector internals.
    /// Defaulted so peers/configs predating the field still deserialize.
    #[serde(default)]
    pub game: Option<String>,
    /// This peer's currently-broadcast music track and playback timeline, or
    /// `None` when not broadcasting. Defaulted so pre-W22 peers deserialize.
    #[serde(default)]
    pub music: Option<MusicPresence>,
}

/// The locally-owned, "sticky" pieces of our own presence: the identity fields
/// that change only on explicit user action and persist for the whole core
/// session. The remaining `PeerState` fields are *volatile* — mute state, current
/// `addr`, and the active screen-share ticket are read fresh at each announce — so
/// they are passed into [`SelfPresence::to_state`] rather than stored here.
///
/// This is the single source of truth for building our own `PeerState`: core
/// reconstructs self-state in several command branches (join, mute toggle, avatar
/// change, screen-share start/stop), and centralizing the `PeerState` literal here
/// means a new presence field is added in exactly one place instead of at every
/// call site.
#[derive(Debug, Clone, Default)]
pub struct SelfPresence {
    pub name: String,
    pub avatar: crate::avatar::Avatar,
    /// The display label of the game we're currently broadcasting, or `None` when
    /// game presence is off / nothing is detected. Already sanitized + capped
    /// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
    /// the outgoing announce carries a safe value.
    pub game: Option<String>,
    /// Our current broadcast timeline, or `None` when not broadcasting / not playing.
    pub music: Option<MusicPresence>,
}

impl SelfPresence {
    /// Combine the sticky identity fields with the volatile per-announce fields
    /// (`is_muted`, current `addr`, active-share `sharing` ticket) into a full
    /// `PeerState` ready to announce over the gossip presence plane.
    pub fn to_state(
        &self,
        is_muted: bool,
        addr: iroh::EndpointAddr,
        sharing: Option<String>,
    ) -> PeerState {
        PeerState {
            name: self.name.clone(),
            is_muted,
            addr,
            sharing,
            avatar: self.avatar.clone(),
            game: self.game.clone(),
            music: self.music.clone(),
        }
    }
}

#[derive(Debug, Clone)]
pub enum RoomEvent {
    PeerJoined(EndpointId, PeerState),
    /// A peer left *gracefully* (it broadcast a `Leave`). Evict it immediately.
    PeerLeft(EndpointId),
    PeerUpdated(EndpointId, PeerState),
    /// A peer's gossip neighbour link dropped without a graceful `Leave` (network
    /// blip, crash, walked out of range). This is transient: the audio supervisor
    /// keeps redialing, so the core marks the peer "reconnecting" and only evicts
    /// it if the link hasn't recovered within the reconnect grace window. Distinct
    /// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
    /// reconnect path the way it used to.
    PeerConnectionLost(EndpointId),
    /// A validly signed gossip payload was rejected only because its timestamp is
    /// outside the replay-protection window. The peer is not in the roster yet,
    /// so this surfaces as a room-level warning instead of a peer-card state.
    ClockSkewSuspected {
        author: EndpointId,
        skew_ms: i64,
    },
    /// A peer sent a room text-chat message. Carries the sender's id, the
    /// sender-CLAIMED display name (untrusted; the core replaces it with the
    /// roster-bound name before the UI sees it — chat-hardening Phase 2), the
    /// text, and the signed envelope timestamp (display only, never ordering).
    ChatMessage {
        from: EndpointId,
        name: String,
        text: String,
        ts: u64,
        /// Optional file attachment descriptor; the bytes are fetched off-gossip
        /// on the file plane. Already filename-sanitized + size-capped on ingest.
        attachment: Option<crate::files::ChatAttachment>,
    },
}

/// Transport-level link state for a peer, surfaced so the UI can show when a
/// peer's audio connection is being (re)established versus actually carrying
/// audio. Distinct from `RoomEvent`: a peer can be present in the gossip room
/// while its audio link is momentarily down and reconnecting.
#[derive(Debug, Clone)]
pub enum ConnEvent {
    /// No live audio link yet — initial connect or reconnecting after a drop.
    Connecting(EndpointId),
    /// A live audio link is established and carrying datagrams.
    Connected(EndpointId),
    /// The peer closed its link *gracefully* (an explicit QUIC application close,
    /// which only happens on an intentional leave/quit — a network drop yields a
    /// timeout, not this). Distinct from a transient drop so the core can evict
    /// the peer immediately instead of waiting out the reconnect grace window.
    /// This is the prompt, reliable leave signal; the gossip `Leave` is too slow.
    Left(EndpointId),
}

/// Owned snapshot of a peer's *selected* QUIC path (the one currently carrying
/// application data), taken from the live audio connection for the UI's
/// connection-transparency badge. Counters are cumulative for the path's
/// lifetime; rate/loss derivation over a poll window happens in
/// `core::connstats` (which also detects path switches via `remote_addr`).
#[derive(Debug, Clone, PartialEq)]
pub struct PathSnapshot {
    /// True when the path runs through a relay server, false for a direct
    /// (holepunched or local) IP path.
    pub is_relay: bool,
    /// The path's remote transport address: `ip:port` for a direct path, the
    /// relay URL for a relayed one.
    pub remote_addr: String,
    /// Current QUIC round-trip-time estimate for the path.
    pub rtt: std::time::Duration,
    /// Cumulative bytes sent in UDP datagrams on the path.
    pub tx_bytes: u64,
    /// Cumulative bytes received in UDP datagrams on the path.
    pub rx_bytes: u64,
    /// Cumulative UDP datagrams sent on the path (the loss denominator: for our
    /// small voice frames these map ~1:1 to QUIC packets).
    pub tx_datagrams: u64,
    /// Cumulative packets detected lost on the path.
    pub lost_packets: u64,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PeerSpeakTicket {
    pub host_addr: iroh::EndpointAddr,
    pub topic_id: [u8; 32],
    /// Cosmetic room label (W7), chosen by the creator and carried in the ticket
    /// so everyone who joins via it inherits the same label and reports "in
    /// HangOut" in presence. Untrusted on receipt (it rides a peer-shared ticket)
    /// — sanitize before display. `#[serde(default)]` keeps older, pre-label
    /// tickets parseable (they decode to an empty label).
    #[serde(default)]
    pub name: String,
}

impl PeerSpeakTicket {
    /// Member-issued ticket (W7 P3): re-stamp an existing ticket string with our
    /// OWN address while keeping its room `topic_id` AND its cosmetic `name`, so
    /// any member can hand out a working door that bootstraps off themselves — the
    /// mechanism that lets a room outlive its creator. A no-op (returns the input
    /// unchanged) if the string can't be parsed. Re-stamping with the same address
    /// is idempotent.
    pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
        match ticket_str.parse::<PeerSpeakTicket>() {
            Ok(t) => PeerSpeakTicket {
                host_addr: my_addr,
                topic_id: t.topic_id,
                name: t.name,
            }
            .to_string(),
            Err(_) => ticket_str.to_string(),
        }
    }

    /// The cosmetic room label embedded in a ticket string, or `""` if the ticket
    /// can't be parsed or carries no label. Pure; used to label the gathering both
    /// in the room UI and in the presence we report to friends.
    pub fn label_of(ticket_str: &str) -> String {
        ticket_str
            .parse::<PeerSpeakTicket>()
            .map(|t| t.name)
            .unwrap_or_default()
    }

    /// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
    /// can't be parsed. Pure; used as the stable room identity for de-duplicating
    /// the recents list (the host address and label change between members/sessions,
    /// but the topic uniquely identifies the gathering).
    pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
        ticket_str
            .parse::<PeerSpeakTicket>()
            .ok()
            .map(|t| t.topic_id)
    }
}

impl std::fmt::Display for PeerSpeakTicket {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // serde_json on a struct of String/[u8;32] fields is infallible in practice,
        // but Display can't surface an error, so fall back to an empty ticket body.
        let serialized = serde_json::to_vec(self).unwrap_or_default();
        let encoded = base64::Engine::encode(
            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
            &serialized,
        );
        f.write_str(&encoded)
    }
}

impl FromStr for PeerSpeakTicket {
    type Err = NetError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
            .map_err(|e| NetError::InvalidTicket(e.to_string()))?;
        let ticket: PeerSpeakTicket =
            serde_json::from_slice(&decoded).map_err(|e| NetError::InvalidTicket(e.to_string()))?;
        Ok(ticket)
    }
}

#[async_trait]
pub trait NetworkTransport: Send + Sync {
    /// Establish (or ensure) a connection to a peer and set up its send path.
    /// Idempotent; safe to call again for an already-connected peer — a repeat
    /// call refreshes the retained dial address, so calling it again when a peer
    /// re-announces (e.g. on a new address) keeps the dialer able to re-reach it.
    /// The full `EndpointAddr` (relay + direct addrs) is retained so reconnects
    /// dial it directly instead of depending on a lookup that a transient gossip
    /// `Leave`/`NeighborDown` may have purged.
    async fn connect_peer(&self, addr: iroh::EndpointAddr);

    /// Tear down the connection and send path for a peer that has left.
    async fn disconnect_peer(&self, peer_id: EndpointId);

    /// Fan a single audio datagram out to every connected peer. Non-blocking:
    /// per-peer queues drop the oldest-pending frame when full, so a slow link
    /// can never stall the capture/encode thread. Callable from any thread.
    fn broadcast(&self, data: Bytes);

    /// Subscribes to incoming datagrams from any peer.
    async fn receive_datagrams(&self) -> Result<Receiver<(EndpointId, Bytes)>, NetError>;

    /// Subscribes to per-peer link-state changes (connecting / connected).
    async fn subscribe_conn_events(&self) -> Result<Receiver<ConnEvent>, NetError>;
}

#[async_trait]
pub trait RoomState: Send + Sync {
    /// Joins a room using a gossip ticket string and announces our state.
    ///
    /// `extra_bootstrap` are additional peer addresses to dial when entering the
    /// gossip swarm, on top of the ticket's host. This is what lets the ROOM
    /// CREATOR rejoin a room they left: their own ticket lists only themselves as
    /// host, so without retained peers they'd have no dial target and never
    /// re-enter the swarm (bug A8). Callers with no prior peers pass `vec![]`.
    async fn join(
        &self,
        ticket: &str,
        self_state: PeerState,
        extra_bootstrap: Vec<EndpointAddr>,
    ) -> Result<(), NetError>;

    /// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
    async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;

    /// Ask the active gossip topic to connect to retained peer addresses without
    /// leaving or replacing the subscription. This is a recovery primitive only:
    /// it does not add peers to the authenticated room roster. A peer becomes
    /// active only after its normal signed `Announce` is received and verified.
    async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError>;

    /// Remove a peer from the authenticated live roster before background
    /// recovery. This only revokes membership; a fresh verified `Announce` is
    /// required to add the peer again.
    fn mark_peer_disconnected(&self, peer_id: EndpointId);

    /// Broadcasts a room text-chat message authored by us (our display name is
    /// taken from the current self-state), optionally carrying a file attachment
    /// descriptor whose bytes are served separately on the file plane.
    async fn send_chat(
        &self,
        text: String,
        attachment: Option<crate::files::ChatAttachment>,
    ) -> Result<(), NetError>;

    /// Leaves the room and announces departure.
    async fn leave(&self) -> Result<(), NetError>;

    /// Returns a list of currently active peers in the room.
    fn active_peers(&self) -> Vec<(EndpointId, PeerState)>;

    /// Subscribes to room events (peer joined, peer left, peer updated).
    async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
}

pub mod gossip;
pub mod iroh_impl;

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

    fn sample_peer_state() -> PeerState {
        let secret = SecretKey::generate();
        let public = secret.public();
        let addr = EndpointAddr::from(public);
        PeerState {
            name: "TestPeer".to_string(),
            is_muted: false,
            addr,
            sharing: None,
            avatar: crate::avatar::Avatar::default(),
            game: None,
            music: None,
        }
    }

    #[test]
    fn test_ticket_round_trip() {
        let state = sample_peer_state();
        let topic_id = [7u8; 32];
        let original_ticket = PeerSpeakTicket {
            host_addr: state.addr.clone(),
            topic_id,
            name: "HangOut".to_string(),
        };
        let ticket_str = original_ticket.to_string();
        let parsed_ticket = ticket_str.parse::<PeerSpeakTicket>().unwrap();
        assert_eq!(parsed_ticket.host_addr.id, original_ticket.host_addr.id);
        assert_eq!(parsed_ticket.topic_id, original_ticket.topic_id);
        assert_eq!(parsed_ticket.name, "HangOut");
    }

    #[test]
    fn test_ticket_label_helpers_and_backcompat() {
        let host = SecretKey::generate().public();
        let topic_id = [3u8; 32];
        // A labelled ticket: restamp keeps the label, label_of reads it.
        let labelled = PeerSpeakTicket {
            host_addr: EndpointAddr::from(host),
            topic_id,
            name: "HangOut".into(),
        }
        .to_string();
        assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
        let member = SecretKey::generate().public();
        let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
        assert_eq!(PeerSpeakTicket::label_of(&restamped), "HangOut");
        // An unparseable ticket has no label rather than panicking.
        assert_eq!(PeerSpeakTicket::label_of("not-a-ticket"), "");
        // topic_of reads the room identity, and returns None for a bad ticket.
        assert_eq!(PeerSpeakTicket::topic_of(&labelled), Some(topic_id));
        assert_eq!(PeerSpeakTicket::topic_of(&restamped), Some(topic_id));
        assert_eq!(PeerSpeakTicket::topic_of("not-a-ticket"), None);
        // Backward-compat: a pre-label ticket JSON (no `name` key) still parses,
        // defaulting the label to "".
        let legacy_json = serde_json::json!({
            "host_addr": serde_json::to_value(EndpointAddr::from(host)).unwrap(),
            "topic_id": topic_id.to_vec(),
        });
        let legacy_str = base64::Engine::encode(
            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
            serde_json::to_vec(&legacy_json).unwrap(),
        );
        let parsed = legacy_str.parse::<PeerSpeakTicket>().unwrap();
        assert_eq!(parsed.name, "");
    }

    #[test]
    fn test_malformed_rejection() {
        // empty string
        let res1 = "".parse::<PeerSpeakTicket>();
        assert!(matches!(res1, Err(NetError::InvalidTicket(_))));

        // non-base64 garbage
        let res2 = "!!!not base64!!!".parse::<PeerSpeakTicket>();
        assert!(matches!(res2, Err(NetError::InvalidTicket(_))));

        // valid URL-safe-base64 that decodes to non-JSON bytes
        let bad_json = b"hello world";
        let encoded =
            base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json);
        let res3 = encoded.parse::<PeerSpeakTicket>();
        assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
    }

    #[test]
    fn test_restamp_swaps_addr_keeps_topic() {
        // A ticket "from" the host, then re-stamped by another member.
        let host = SecretKey::generate().public();
        let member = SecretKey::generate().public();
        let topic_id = [42u8; 32];
        let original = PeerSpeakTicket {
            host_addr: EndpointAddr::from(host),
            topic_id,
            name: "HangOut".into(),
        }
        .to_string();

        let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
        let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
        // Same room (and label), but the door now points at the member, not the host.
        assert_eq!(restamped.topic_id, topic_id);
        assert_eq!(restamped.host_addr.id, member);
        assert_ne!(restamped.host_addr.id, host);
        assert_eq!(restamped.name, "HangOut");
    }

    #[test]
    fn test_restamp_is_idempotent_for_same_addr() {
        let me = SecretKey::generate().public();
        let topic_id = [7u8; 32];
        let mine = PeerSpeakTicket {
            host_addr: EndpointAddr::from(me),
            topic_id,
            name: String::new(),
        }
        .to_string();
        // Re-stamping my own ticket with my own addr changes nothing.
        assert_eq!(
            PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)),
            mine
        );
    }

    #[test]
    fn test_restamp_passes_through_unparseable() {
        let me = SecretKey::generate().public();
        // A malformed ticket is returned unchanged (the join will fail anyway).
        assert_eq!(
            PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)),
            "not-a-ticket"
        );
    }

    #[test]
    fn self_presence_builds_peer_state_with_volatile_fields() {
        let addr = EndpointAddr::from(SecretKey::generate().public());
        let presence = SelfPresence {
            name: "Alice".to_string(),
            avatar: crate::avatar::Avatar::default(),
            game: Some("Half-Life 2".to_string()),
            music: None,
        };
        // Volatile fields come from the call; sticky fields from the struct.
        let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
        assert_eq!(muted.name, "Alice");
        assert!(muted.is_muted);
        assert_eq!(muted.addr.id, addr.id);
        assert_eq!(muted.sharing.as_deref(), Some("ticket"));
        assert_eq!(muted.avatar, crate::avatar::Avatar::default());
        assert_eq!(muted.game.as_deref(), Some("Half-Life 2"));
        // The same sticky presence yields different volatile fields per announce.
        let unmuted = presence.to_state(false, addr.clone(), None);
        assert!(!unmuted.is_muted);
        assert_eq!(unmuted.sharing, None);
        assert_eq!(unmuted.name, muted.name);
    }

    #[test]
    fn test_peer_state_serde_round_trip() {
        let original = sample_peer_state();
        let serialized = serde_json::to_string(&original).unwrap();
        let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
        assert_eq!(original, deserialized);
    }

    #[test]
    fn music_presence_serde_round_trip() {
        let original = MusicPresence {
            id: [3u8; 32],
            name: "track.ogg".to_string(),
            size: 1234,
            paused: false,
            anchor_ms: 1_700_000_000_000,
            position_ms: 42_000,
            next_id: None,
            next_size: None,
        };
        let serialized = serde_json::to_string(&original).unwrap();
        let deserialized: MusicPresence = serde_json::from_str(&serialized).unwrap();
        assert_eq!(original, deserialized);
    }
}
