//! ZodiaNetwork — composes p2panda-net's modular building blocks into a
//! single handle the app layer holds for all network operations.
//!
//! Component roles:
//!   AddressBook  — knows which peers are on which topics, manages reconnects
//!   Endpoint     — QUIC transport (iroh), the actual I/O primitive
//!   MdnsDiscovery — local LAN peer discovery
//!   Discovery    — internet-wide peer discovery via p2panda DHT random walk
//!   Gossip       — topic-scoped ephemeral broadcast (Tier-0 announcements)
//!
//! The app receives all domain events via a `tokio::sync::mpsc::Receiver<ZodiaNetEvent>`.

use std::collections::HashSet;
use std::sync::{Arc, Mutex};

use crate::channel::{ChannelMsg, DirectChannel};
use crate::{AnnounceBlob, PeerId, ZodiaNetEvent};
use iroh::protocol::{AcceptError, ProtocolHandler};
use ed25519_dalek::SigningKey;
use futures_util::StreamExt;
use p2panda_core::PrivateKey as PandaKey;
use p2panda_net::address_book::report::ConnectionOutcome;
use p2panda_net::addrs::NodeInfo;
use p2panda_net::gossip::{GossipHandle, GossipSubscription};
use p2panda_net::iroh_endpoint::from_public_key;
use p2panda_net::iroh_mdns::{MdnsDiscovery, MdnsDiscoveryMode};
use p2panda_net::{AddressBook, Discovery, Endpoint, Gossip, NodeId};
use iroh::EndpointAddr;
use thiserror::Error;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::{debug, info, instrument, warn};
use zodia_core::{BirthData, compute_positions, topic_key_global, topic_keys_for_chart};

/// Zodia's network identifier — all nodes sharing this ID form one logical
/// overlay; peers on different IDs are invisible to each other.
pub const NETWORK_ID: [u8; 32] = *b"zodia-network-2024\0\0\0\0\0\0\0\0\0\0\0\0\0\0";

/// Self-hosted iroh relay for NAT traversal.  Must match the value in tools/bootstrap.
/// Runs on stargaze.whatdoyouliketodo.com behind Caddy (TLS terminated by Caddy,
/// iroh-relay listens on localhost:3340 with no TLS).
pub const RELAY_ZODIA: &str = "https://stargaze.whatdoyouliketodo.com.";

/// All configured relay URLs — used to build the relay map and to hint the
/// bootstrap node address.
pub const ALL_RELAYS: &[&str] = &[RELAY_ZODIA];

/// Bootstrap node public key (hex-decoded bytes).
///
/// CRITICAL: This node must be running `zodia-bootstrap` compiled with the same
/// `NETWORK_ID` (`zodia-network-2024…`).  If it is unreachable the PSI-Hash
/// Discovery walkers stall until their timeout, then fall back to whatever the
/// address book already contains (i.e. mDNS-discovered LAN peers).  Internet-wide
/// discovery is effectively dead without a reachable bootstrap node.
///
/// Set to `None` to rely on mDNS-only discovery (LAN only).
const BOOTSTRAP_NODE_ID: Option<[u8; 32]> = Some([176, 45, 141, 10, 244, 112, 14, 59, 107, 231, 19, 72, 114, 30, 215, 65, 72, 50, 80, 56, 10, 112, 192, 16, 171, 119, 103, 105, 89, 230, 178, 181]);

/// Protocol identifier for direct peer-to-peer consent exchanges.
pub const CONSENT_PROTOCOL: &[u8] = b"zodia/tier1/1";

#[derive(Debug, Error)]
pub enum NetworkError {
    #[error("address book: {0}")]
    AddressBook(String),
    #[error("endpoint: {0}")]
    Endpoint(String),
    #[error("gossip: {0}")]
    Gossip(String),
    #[error("discovery: {0}")]
    Discovery(String),
    #[error("publish failed")]
    Publish,
}

/// Configuration for spawning a ZodiaNetwork.
pub struct NetworkConfig {
    /// The peer's ed25519 identity key.  The same bytes are used as the
    /// p2panda identity (operation signing) AND the iroh NodeId (transport).
    pub signing_key: SigningKey,
}

/// The live network handle.  Holds all p2panda-net components alive.
///
/// Cheap to clone — all inner components are reference-counted.
pub struct ZodiaNetwork {
    gossip: Gossip,
    /// One handle per degree-bucket topic derived from the peer's natal chart.
    topic_handles: Vec<GossipHandle>,
    endpoint: Endpoint,
    /// Pre-constructed, signed announce blob for this peer.
    my_blob: AnnounceBlob,
    event_tx: mpsc::Sender<ZodiaNetEvent>,
    // Keep discovery components alive (their drop = shutdown).
    address_book: AddressBook,
    _mdns: MdnsDiscovery,
    _discovery: Discovery,
}

impl ZodiaNetwork {
    /// Spawn all network components and return the network handle plus the
    /// event channel receiver the app should poll.
    #[instrument(skip_all, fields(geohash = %birth.geohash))]
    pub async fn spawn(
        config: NetworkConfig,
        birth: &BirthData,
    ) -> Result<(Self, mpsc::Receiver<ZodiaNetEvent>), NetworkError> {
        // Derive the p2panda PrivateKey from the same scalar bytes.
        let panda_key = PandaKey::from_bytes(config.signing_key.as_bytes());

        let address_book = AddressBook::builder()
            .spawn()
            .await
            .map_err(|e| NetworkError::AddressBook(e.to_string()))?;
        debug!("address book ready");

        // Seed the address book with the bootstrap peer so the gossip swarm has
        // somewhere to connect on first start, even on a fresh install.
        if let Some(id_bytes) = BOOTSTRAP_NODE_ID {
            if let Ok(bootstrap_pk) = NodeId::from_bytes(&id_bytes) {
                let addr = EndpointAddr::new(from_public_key(bootstrap_pk))
                    .with_relay_url(RELAY_ZODIA.parse().unwrap());
                let _ = address_book
                    .insert_node_info(NodeInfo::from(addr).bootstrap())
                    .await;
                debug!("bootstrap peer added to address book");
            }
        }

        let mut ep_builder = Endpoint::builder(address_book.clone())
            .network_id(NETWORK_ID)
            .private_key(panda_key);
        for url in ALL_RELAYS {
            ep_builder = ep_builder.relay_url(url.parse().unwrap());
        }
        let endpoint = ep_builder
            .spawn()
            .await
            .map_err(|e| NetworkError::Endpoint(e.to_string()))?;
        let node_id_hex = hex::encode_upper(&endpoint.node_id().as_bytes()[..4]);
        info!(node_id = %node_id_hex, "endpoint ready");

        let mdns = MdnsDiscovery::builder(address_book.clone(), endpoint.clone())
            .mode(MdnsDiscoveryMode::Active)
            .spawn()
            .await
            .map_err(|e| NetworkError::Discovery(e.to_string()))?;
        debug!("mDNS discovery active");

        let discovery = Discovery::builder(address_book.clone(), endpoint.clone())
            .spawn()
            .await
            .map_err(|e| NetworkError::Discovery(e.to_string()))?;
        debug!("DHT discovery active");

        let gossip = Gossip::builder(address_book.clone(), endpoint.clone())
            .spawn()
            .await
            .map_err(|e| NetworkError::Gossip(e.to_string()))?;
        debug!("gossip engine ready");

        // Always include the global topic so peers are discoverable even when
        // the network is small and no aspect-bucket overlap exists yet.
        // Aspect-derived topics layer on top for meaningful synastry matching
        // as the network grows.
        let mut topic_keys = vec![topic_key_global()];
        match compute_positions(birth.jdn) {
            Ok(positions) => {
                let aspect_keys = topic_keys_for_chart(&positions);
                info!(global = 1, aspect = aspect_keys.len(), "subscribing to topics");
                topic_keys.extend(aspect_keys);
            }
            Err(e) => {
                warn!("ephemeris error, skipping aspect topics: {e}");
            }
        };

        let mut topic_handles = Vec::with_capacity(topic_keys.len());
        for key in &topic_keys {
            let handle = gossip
                .stream(key.0)
                .await
                .map_err(|e| NetworkError::Gossip(e.to_string()))?;
            topic_handles.push(handle);
        }

        let my_blob = AnnounceBlob::sign(birth, &config.signing_key);
        let (event_tx, event_rx) = mpsc::channel(256);

        // Register the Tier-1 ALPN so incoming connections are accepted.
        // Without this registration iroh refuses the TLS handshake with
        // "error 120: peer doesn't support any known protocol".
        endpoint
            .accept(CONSENT_PROTOCOL, ConsentHandler { event_tx: event_tx.clone() })
            .await
            .map_err(|e| NetworkError::Endpoint(e.to_string()))?;
        debug!("consent ALPN registered");

        // Dedup set shared across all topic listeners — a peer may be
        // discoverable via several shared topics and we only want one event.
        let seen_peers: Arc<Mutex<HashSet<[u8; 32]>>> = Arc::new(Mutex::new(HashSet::new()));
        for handle in &topic_handles {
            spawn_gossip_listener(handle.subscribe(), event_tx.clone(), Arc::clone(&seen_peers));
        }

        let net = ZodiaNetwork {
            gossip,
            topic_handles,
            endpoint,
            my_blob,
            event_tx,
            address_book,
            _mdns: mdns,
            _discovery: discovery,
        };
        info!("ZodiaNetwork spawned");
        Ok((net, event_rx))
    }

    /// Broadcast our signed Tier-0 announce blob to both swarm topics.
    ///
    /// Call this once on startup and again whenever re-announcing (e.g. after
    /// a long offline period).
    #[instrument(skip(self))]
    pub async fn publish_announce(&self) -> Result<(), NetworkError> {
        let bytes = self.my_blob.to_cbor();
        for handle in &self.topic_handles {
            handle
                .publish(bytes.clone())
                .await
                .map_err(|_| NetworkError::Publish)?;
        }
        // Note: published to the gossip overlay, not directly to peer count.
        // If the overlay has no peers yet the message is silently discarded.
        debug!(topics = self.topic_handles.len(), "announce published to gossip overlay");
        Ok(())
    }

    /// Open a direct QUIC connection to `peer` for the Tier-1 handshake.
    ///
    /// Retries up to 3 times with short delays to recover from transient path
    /// failures and stale address-book entries.  On success the address book is
    /// explicitly marked `Successful` so subsequent attempts are not blocked by
    /// a stale timestamp left over from the previous failure.
    #[instrument(skip(self), fields(peer = %hex::encode_upper(&peer.0[..4])))]
    pub async fn connect_peer(&self, peer: &PeerId) -> Result<crate::channel::DirectChannel, NetworkError> {
        const DELAYS_MS: [u64; 2] = [1_000, 3_000];
        let node_id = peer.to_node_id();
        let mut last_err = String::new();

        for attempt in 0..3u8 {
            if attempt > 0 {
                let delay = DELAYS_MS[(attempt - 1) as usize];
                debug!(attempt, delay_ms = delay, "retrying consent connection");
                sleep(Duration::from_millis(delay)).await;
            }
            debug!(attempt, "opening consent QUIC connection");
            match self.endpoint.connect(node_id, CONSENT_PROTOCOL).await {
                Ok(conn) => {
                    // Clear any stale mark so the next connect attempt is not
                    // blocked by a previously recorded failure.
                    let _ = self.address_book
                        .report(node_id, ConnectionOutcome::Successful)
                        .await;
                    info!(attempt, "consent channel open");
                    return Ok(crate::channel::DirectChannel::from_connection(conn));
                }
                Err(e) => {
                    last_err = e.to_string();
                    warn!(attempt, err = %last_err, "consent connect failed");
                }
            }
        }
        Err(NetworkError::Endpoint(last_err))
    }

    /// Our own node ID (= public key bytes of the identity keypair).
    pub fn node_id(&self) -> PeerId {
        let bytes: [u8; 32] = *self.endpoint.node_id().as_bytes();
        PeerId(bytes)
    }

    /// Clone of the iroh `Endpoint` — pass to `ZodiaSyncNode::spawn`.
    pub fn endpoint(&self) -> Endpoint {
        self.endpoint.clone()
    }

    /// Clone of the gossip engine — pass to `ZodiaSyncNode::spawn`.
    pub fn gossip(&self) -> Gossip {
        self.gossip.clone()
    }

    /// A clone of the event sender — allows other subsystems (e.g. the AV
    /// layer) to inject events into the same stream.
    pub fn event_sender(&self) -> mpsc::Sender<ZodiaNetEvent> {
        self.event_tx.clone()
    }

    /// Register an established `DirectChannel` and start listening for incoming
    /// `ChannelMsg` messages from that peer, translating them into
    /// `ZodiaNetEvent`s on the shared event stream.
    ///
    /// Call this after a successful outgoing `connect_peer()` AND after
    /// accepting an incoming connection, so that call signaling is always
    /// handled in one place.
    pub fn accept_channel(&self, peer_id: PeerId, channel: DirectChannel) {
        spawn_channel_listener(peer_id, channel, self.event_tx.clone());
    }
}

// ── consent protocol handler ──────────────────────────────────────────────────

/// Accepts incoming `CONSENT_PROTOCOL` QUIC connections and emits an
/// `IncomingChannel` event so the app layer can register the channel.
#[derive(Debug, Clone)]
struct ConsentHandler {
    event_tx: mpsc::Sender<ZodiaNetEvent>,
}

impl ProtocolHandler for ConsentHandler {
    async fn accept(&self, conn: iroh::endpoint::Connection) -> Result<(), AcceptError> {
        let peer_id = PeerId(*conn.remote_id().as_bytes());
        let channel = DirectChannel::from_connection(conn);
        let _ = self.event_tx.send(ZodiaNetEvent::IncomingChannel { peer_id, channel }).await;
        Ok(())
    }
}

// ── channel listener ──────────────────────────────────────────────────────────

/// Spawn a background task that reads `ChannelMsg`s from `channel` and
/// forwards them as `ZodiaNetEvent`s.
pub(crate) fn spawn_channel_listener(
    peer_id: PeerId,
    channel: DirectChannel,
    tx: mpsc::Sender<ZodiaNetEvent>,
) {
    tokio::spawn(async move {
        loop {
            match channel.recv_msg().await {
                Ok(ChannelMsg::CallOffer { session_id }) => {
                    let _ = tx.send(ZodiaNetEvent::CallOffer { from: peer_id.clone(), session_id }).await;
                }
                Ok(ChannelMsg::CallAccept { session_id }) => {
                    let _ = tx.send(ZodiaNetEvent::CallAccepted { from: peer_id.clone(), session_id }).await;
                }
                Ok(ChannelMsg::CallReject { .. }) => {
                    let _ = tx.send(ZodiaNetEvent::CallRejected { from: peer_id.clone() }).await;
                }
                Ok(ChannelMsg::CallHangup { .. }) => {
                    let _ = tx.send(ZodiaNetEvent::CallHungUp { from: peer_id.clone() }).await;
                }
                Ok(ChannelMsg::ChatMsg { text }) => {
                    let _ = tx.send(ZodiaNetEvent::ChatReceived { from: peer_id.clone(), text }).await;
                }
                Ok(ChannelMsg::StatusUpdate { status }) => {
                    let _ = tx.send(ZodiaNetEvent::PeerStatusChanged { peer_id: peer_id.clone(), status }).await;
                }
                Ok(ChannelMsg::RelayMsg { dest, payload }) => {
                    let _ = tx.send(ZodiaNetEvent::RelayReceived {
                        via: peer_id.clone(),
                        dest: PeerId(dest),
                        payload,
                    }).await;
                }
                Ok(ChannelMsg::InterpShare { entries }) => {
                    let _ = tx.send(ZodiaNetEvent::InterpReceived { from: peer_id.clone(), entries }).await;
                }
                Ok(_) => {} // ConsentHandshake handled at connect time
                Err(e) => {
                    debug!(peer = %hex::encode_upper(&peer_id.0[..4]), err = %e, "channel closed");
                    let _ = tx.send(ZodiaNetEvent::PeerChannelClosed { peer_id: peer_id.clone() }).await;
                    break;
                }
            }
        }
    });
}

// ── gossip listener ───────────────────────────────────────────────────────────

fn spawn_gossip_listener(
    mut sub: GossipSubscription,
    tx: mpsc::Sender<ZodiaNetEvent>,
    seen: Arc<Mutex<HashSet<[u8; 32]>>>,
) {
    tokio::spawn(async move {
        while let Some(result) = sub.next().await {
            match result {
                Ok(bytes) => match AnnounceBlob::from_cbor(&bytes) {
                    Ok(blob) if blob.verify().is_ok() => {
                        // Dedup: a peer may appear on several shared topics.
                        let is_new = seen.lock().map(|mut s| s.insert(blob.pubkey)).unwrap_or(true);
                        if !is_new {
                            continue;
                        }
                        let peer_id = PeerId(blob.pubkey);
                        debug!(
                            peer = %hex::encode_upper(&peer_id.0[..4]),
                            geohash = %blob.geohash_prefix,
                            solar_month = blob.solar_month,
                            "peer discovered via gossip"
                        );
                        let _ = tx.send(ZodiaNetEvent::PeerDiscovered { peer_id, blob }).await;
                    }
                    Ok(_) => {
                        warn!("gossip: received blob with invalid signature — discarding");
                    }
                    Err(e) => {
                        debug!(err = %e, "gossip: CBOR decode error — discarding");
                    }
                },
                Err(_lagged) => {
                    warn!("gossip: broadcast channel lagged — messages dropped");
                }
            }
        }
    });
}
