//! Community membership lifecycle: join, leave (stop-forward), rejoin.
//!
//! A [`Membership`] is the per-`(user, community)` lifecycle row that decides
//! whether a user's community-scoped contributions are eligible to flow *right
//! now*, and under which *epoch* they are stamped. The load-bearing field is the
//! atomic [`Membership::stop_forward_at_ns`] gate: the signal write path reads it
//! with a single `Acquire` load, so the "<1s stop-forward" guarantee holds by
//! construction — it never routes through the 60s sweeper.
//!
//! Aggregation of community contributions reuses the M8 per-shard CRDT on the
//! community aggregator; this module governs only *forward* eligibility on the
//! contributing node. Retroactive purge is M9p3.

use std::sync::atomic::{AtomicU64, Ordering};

use dashmap::DashMap;
use serde::{Deserialize, Serialize};

use super::{scope::CommunityId, share_policy::SharePolicy};
use crate::schema::TidalError;

/// Identifies a user participating in a community overlay.
///
/// A thin newtype over the entity id so membership keys never collide with raw
/// `EntityId` usage elsewhere. `UserId(0)` is reserved (no user).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct UserId(pub u64);

impl UserId {
    /// The raw user id.
    #[must_use]
    pub const fn as_u64(self) -> u64 {
        self.0
    }
}

/// The lifecycle state of a `(user, community)` membership.
///
/// Transitions: `Joined -> LeavingStopForward -> Left -> Rejoined -> ...`.
/// Each `Rejoined` opens a new epoch; replay never crosses epochs ambiguously.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum MembershipState {
    /// Active member; community-scoped contributions flow.
    Joined = 0,
    /// Leave initiated; `stop_forward_at_ns` is set and new community
    /// contributions are gated off, but no purge has occurred.
    LeavingStopForward = 1,
    /// Settled "left" state; contributions stay gated until a rejoin.
    Left = 2,
    /// Re-entered the community under a new epoch.
    Rejoined = 3,
}

impl MembershipState {
    /// The `u8` wire discriminant.
    #[must_use]
    pub const fn discriminant(self) -> u8 {
        self as u8
    }

    /// Whether contributions are allowed to flow in this state (before
    /// consulting the atomic stop-forward gate).
    #[must_use]
    pub const fn allows_contribution(self) -> bool {
        matches!(self, Self::Joined | Self::Rejoined)
    }
}

/// How a leave is performed.
///
/// `StopForward` is the M9p2 guarantee: block new contributions immediately,
/// leave existing aggregates untouched. `Purge` (a superset, M9p3) additionally
/// removes prior contributions; it is declared here so the API surface is stable
/// but is rejected with `TidalError::invalid_input` until M9p3 lands.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LeaveMode {
    /// Block new contributions; keep prior aggregates (M9p2).
    StopForward,
    /// Stop-forward **and** retroactively purge prior contributions (M9p3).
    Purge,
}

/// A durable per-`(user, community)` membership row.
///
/// The source of truth lives in [`MembershipRegistry`]; this struct is what gets
/// persisted under `Tag::Membership`. `stop_forward_at_ns` is the only mutable
/// hot-path field and is an atomic so the signal write path can gate without
/// taking a lock.
#[derive(Debug, Serialize, Deserialize)]
pub struct Membership {
    /// The participating user.
    pub user_id: UserId,
    /// The community overlay being joined.
    pub community_id: CommunityId,
    /// Current lifecycle state.
    pub state: MembershipState,
    /// Monotonic epoch; increments by 1 on each rejoin. First join = 0.
    pub epoch: u32,
    /// The share contract in force for this membership (decides per-intent
    /// eligibility). Its `version` is stamped into contribution provenance.
    pub share_policy: SharePolicy,
    /// Wall-clock ns at first join (never changes across epochs).
    pub joined_at_ns: u64,
    /// Wall-clock ns at which contributions were stop-forwarded.
    ///
    /// `0` means "not stop-forwarded — contributions flow". Read on the
    /// synchronous signal write path with `Ordering::Acquire`.
    #[serde(with = "crate::governance::serde_util::atomic_u64")]
    pub stop_forward_at_ns: AtomicU64,
}

impl Clone for Membership {
    fn clone(&self) -> Self {
        Self {
            user_id: self.user_id,
            community_id: self.community_id,
            state: self.state,
            epoch: self.epoch,
            share_policy: self.share_policy.clone(),
            joined_at_ns: self.joined_at_ns,
            stop_forward_at_ns: AtomicU64::new(self.stop_forward_at_ns.load(Ordering::Acquire)),
        }
    }
}

impl Membership {
    /// Construct a fresh `Joined` membership at `epoch`.
    #[must_use]
    pub const fn joined(
        user_id: UserId,
        community_id: CommunityId,
        share_policy: SharePolicy,
        epoch: u32,
        now_ns: u64,
    ) -> Self {
        Self {
            user_id,
            community_id,
            state: MembershipState::Joined,
            epoch,
            share_policy,
            joined_at_ns: now_ns,
            stop_forward_at_ns: AtomicU64::new(0),
        }
    }

    /// Whether community-scoped contributions are currently gated off.
    ///
    /// `O(1)` atomic load — this is the <1s stop-forward gate. Never the sweeper.
    #[must_use]
    pub fn is_stop_forwarded(&self) -> bool {
        self.stop_forward_at_ns.load(Ordering::Acquire) != 0
    }

    /// Engage the stop-forward gate at `now_ns`. Idempotent: the first non-zero
    /// store wins; later calls do not move the timestamp backward.
    pub fn engage_stop_forward(&self, now_ns: u64) {
        let _ = self.stop_forward_at_ns.compare_exchange(
            0,
            now_ns.max(1),
            Ordering::AcqRel,
            Ordering::Acquire,
        );
    }

    /// Clear the gate (used on rejoin so the new epoch flows).
    pub fn clear_stop_forward(&self) {
        self.stop_forward_at_ns.store(0, Ordering::Release);
    }

    /// Whether a contribution of `signal_name` is eligible to flow *right now*:
    /// the state allows it, the gate is open, and the share policy admits the
    /// intent. `O(1)`.
    #[must_use]
    pub fn is_contribution_eligible(&self, signal_name: &str) -> bool {
        self.state.allows_contribution()
            && !self.is_stop_forwarded()
            && self.share_policy.is_signal_share_eligible(
                super::scope::SignalScope::Community(self.community_id),
                signal_name,
            )
    }
}

/// In-memory registry of all `(user, community)` memberships.
///
/// Source of truth for the lifecycle gate. Reads on the signal write path go
/// through `DashMap::get` (sharded, lock-free for distinct keys). Persisted
/// row-by-row under `Tag::Membership` and rebuilt on `open()`.
#[derive(Debug, Default)]
pub struct MembershipRegistry {
    rows: DashMap<(UserId, CommunityId), Membership>,
}

impl MembershipRegistry {
    /// Construct an empty registry.
    #[must_use]
    pub fn new() -> Self {
        Self {
            rows: DashMap::new(),
        }
    }

    /// Fast path used on the signal write path before any community work.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.rows.is_empty()
    }

    /// Number of membership rows (for diagnostics/tests).
    #[must_use]
    pub fn len(&self) -> usize {
        self.rows.len()
    }

    /// The active epoch for `(user, community)`, if a membership exists.
    #[must_use]
    pub fn epoch(&self, user: UserId, community: CommunityId) -> Option<u32> {
        self.rows.get(&(user, community)).map(|m| m.epoch)
    }

    /// The current lifecycle state for `(user, community)`, if any.
    #[must_use]
    pub fn state(&self, user: UserId, community: CommunityId) -> Option<MembershipState> {
        self.rows.get(&(user, community)).map(|m| m.state)
    }

    /// Whether a `signal_name` contribution for `(user, community)` is eligible
    /// to flow right now. Returns `false` (gated) when no membership exists — a
    /// non-member cannot contribute. `O(1)`.
    #[must_use]
    pub fn is_contribution_eligible(
        &self,
        user: UserId,
        community: CommunityId,
        signal_name: &str,
    ) -> bool {
        self.rows
            .get(&(user, community))
            .is_some_and(|m| m.is_contribution_eligible(signal_name))
    }

    /// Insert or replace a membership row.
    pub fn put(&self, m: Membership) {
        self.rows.insert((m.user_id, m.community_id), m);
    }

    /// Apply a closure to an existing membership row, returning whether it existed.
    pub fn with_row<R>(
        &self,
        user: UserId,
        community: CommunityId,
        f: impl FnOnce(&Membership) -> R,
    ) -> Option<R> {
        self.rows.get(&(user, community)).map(|m| f(&m))
    }

    /// Snapshot all rows (for checkpoint persistence).
    #[must_use]
    pub fn snapshot(&self) -> Vec<Membership> {
        self.rows.iter().map(|r| r.value().clone()).collect()
    }

    /// Load a row from durable bytes; malformed bytes return `None`.
    #[must_use]
    pub fn deserialize_row(bytes: &[u8]) -> Option<Membership> {
        serde_json::from_slice(bytes).ok()
    }

    /// Serialize a row to durable bytes.
    ///
    /// A serialize failure is propagated as [`TidalError::Internal`] rather than
    /// swallowed into empty bytes: an empty value deserializes back as a
    /// *missing* membership (the stop-forward gate and epoch would silently
    /// vanish on restart), so the durability path must surface the failure, not
    /// hide it. This struct always serializes cleanly in practice.
    ///
    /// # Errors
    ///
    /// Returns [`TidalError::Internal`] if serialization fails.
    pub fn serialize_row(m: &Membership) -> crate::Result<Vec<u8>> {
        serde_json::to_vec(m)
            .map_err(|e| TidalError::internal("membership_serialize_row", e.to_string()))
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::governance::share_policy::ShareIntent;

    fn policy() -> SharePolicy {
        SharePolicy::community_share(2, [ShareIntent::LowQuality, ShareIntent::Like])
    }

    #[test]
    fn joined_is_eligible_for_allowed_intent() {
        let m = Membership::joined(UserId(1), CommunityId(1), policy(), 0, 1000);
        assert!(m.is_contribution_eligible("low_quality"));
        assert!(m.is_contribution_eligible("like"));
        assert!(!m.is_contribution_eligible("view")); // not in allowed set
    }

    #[test]
    fn stop_forward_gate_blocks_contribution() {
        let m = Membership::joined(UserId(1), CommunityId(1), policy(), 0, 1000);
        assert!(m.is_contribution_eligible("like"));
        m.engage_stop_forward(2000);
        assert!(m.is_stop_forwarded());
        assert!(
            !m.is_contribution_eligible("like"),
            "gate must block contributions"
        );
    }

    #[test]
    fn engage_stop_forward_is_idempotent_first_wins() {
        let m = Membership::joined(UserId(1), CommunityId(1), policy(), 0, 1000);
        m.engage_stop_forward(5000);
        m.engage_stop_forward(9000);
        assert_eq!(m.stop_forward_at_ns.load(Ordering::Acquire), 5000);
    }

    #[test]
    fn clear_stop_forward_reopens_gate() {
        let m = Membership::joined(UserId(1), CommunityId(1), policy(), 1, 1000);
        m.engage_stop_forward(2000);
        m.clear_stop_forward();
        assert!(!m.is_stop_forwarded());
    }

    #[test]
    fn left_state_blocks_even_without_gate() {
        let mut m = Membership::joined(UserId(1), CommunityId(1), policy(), 0, 1000);
        m.state = MembershipState::Left;
        assert!(!m.is_contribution_eligible("like"));
    }

    #[test]
    fn registry_gate_and_epoch() {
        let reg = MembershipRegistry::new();
        assert!(reg.is_empty());
        reg.put(Membership::joined(
            UserId(7),
            CommunityId(3),
            policy(),
            0,
            1000,
        ));
        assert!(!reg.is_empty());
        assert_eq!(reg.epoch(UserId(7), CommunityId(3)), Some(0));
        assert!(reg.is_contribution_eligible(UserId(7), CommunityId(3), "like"));
        // Non-member is always gated.
        assert!(!reg.is_contribution_eligible(UserId(8), CommunityId(3), "like"));
    }

    #[test]
    fn membership_serde_roundtrip_preserves_gate() {
        let m = Membership::joined(UserId(1), CommunityId(2), policy(), 4, 1234);
        m.engage_stop_forward(5678);
        let bytes = MembershipRegistry::serialize_row(&m).unwrap();
        let back = MembershipRegistry::deserialize_row(&bytes).unwrap();
        assert_eq!(back.epoch, 4);
        assert_eq!(back.user_id, UserId(1));
        assert_eq!(back.community_id, CommunityId(2));
        assert_eq!(back.stop_forward_at_ns.load(Ordering::Acquire), 5678);
        assert_eq!(back.state, MembershipState::Joined);
    }

    #[test]
    fn membership_state_discriminants() {
        assert_eq!(MembershipState::Joined.discriminant(), 0);
        assert_eq!(MembershipState::LeavingStopForward.discriminant(), 1);
        assert_eq!(MembershipState::Left.discriminant(), 2);
        assert_eq!(MembershipState::Rejoined.discriminant(), 3);
        assert!(MembershipState::Joined.allows_contribution());
        assert!(MembershipState::Rejoined.allows_contribution());
        assert!(!MembershipState::Left.allows_contribution());
        assert!(!MembershipState::LeavingStopForward.allows_contribution());
    }
}
