//! Community membership lifecycle operations on `TidalDb` (M9p2).
//!
//! `join_community` / `leave_community` / `rejoin_community` manage the
//! per-`(user, community)` lifecycle row; `signal_for_community` is the gated
//! write path for community-scoped contributions. The stop-forward gate is an
//! `O(1)` atomic read on this synchronous path — it never routes through the
//! 60s session sweeper, so the "<1s" guarantee holds by construction. Local
//! profile state is never touched by these methods (local-profile-intact).

use std::{
    hash::{Hash, Hasher},
    sync::{Mutex, MutexGuard, OnceLock},
};

use super::TidalDb;
use crate::{
    governance::{
        Membership, MembershipRegistry, MembershipState, SharePolicy, SignalScope, UserId,
        scope::CommunityId,
    },
    schema::{EntityId, TidalError, Timestamp},
    storage::{Tag, encode_key},
};

/// Number of shards in the per-`(user, community)` lifecycle lock table. A power
/// of two so the index mask is a cheap bitwise AND. 256 shards keep contention
/// negligible on the cold lifecycle path while bounding the static's footprint.
const LIFECYCLE_LOCK_SHARDS: usize = 256;

/// Sharded mutex table serializing the read-modify-write lifecycle transitions
/// (`join` / `leave` / `rejoin`) for a given `(user, community)`.
///
/// The membership registry's `with_row` + `put` is a non-atomic read-then-put:
/// without serialization, a `join` that read the pre-leave row can `put` a fresh
/// `Joined` row over a concurrent `leave`'s `Left` row, silently re-admitting a
/// member who just left and defeating the stop-forward guarantee (a privacy
/// regression, not a transient count error). All three transitions take this
/// lock keyed by `(user, community)` so the registry sees one writer per key.
///
/// The signal write path (`community_contribute`) deliberately does NOT take this
/// lock — it stays lock-free; only the rare lifecycle transitions serialize.
///
/// A process-global static rather than a `TidalDb` field: the lock is purely a
/// serialization device over the registry's read-modify-write, not state tied to
/// a DB instance, so sharing it across instances only ever over-serializes two
/// distinct DBs whose keys collide on a shard — harmless on this cold path.
fn lifecycle_locks() -> &'static [Mutex<()>; LIFECYCLE_LOCK_SHARDS] {
    static LOCKS: OnceLock<[Mutex<()>; LIFECYCLE_LOCK_SHARDS]> = OnceLock::new();
    LOCKS.get_or_init(|| std::array::from_fn(|_| Mutex::new(())))
}

/// Acquire the lifecycle lock guarding `(user, community)`.
///
/// Recovers from a poisoned lock via [`std::sync::PoisonError::into_inner`]: the
/// guarded unit `()` carries no invariant a prior panic could have corrupted, so
/// a poisoned shard is safe to keep using rather than propagating the poison.
fn lock_lifecycle(user: UserId, community: CommunityId) -> MutexGuard<'static, ()> {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    user.as_u64().hash(&mut hasher);
    community.as_u64().hash(&mut hasher);
    let idx = (hasher.finish() as usize) & (LIFECYCLE_LOCK_SHARDS - 1);
    lifecycle_locks()[idx]
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

impl TidalDb {
    /// Join a community overlay under an explicit [`SharePolicy`].
    ///
    /// Creates a `Joined` membership at `epoch = 0`. If a prior `Left`
    /// membership exists this is equivalent to [`rejoin_community`](Self::rejoin_community)
    /// (advancing to the next epoch). Idempotent if already an active member at
    /// the current epoch (the share policy is refreshed). Returns the active epoch.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if the database is not writeable.
    /// - `TidalError::Storage` on persistence failure.
    pub fn join_community(
        &self,
        user: UserId,
        community: CommunityId,
        policy: SharePolicy,
    ) -> crate::Result<u32> {
        self.require_writeable("join_community")?;
        // Serialize the read-modify-write against a concurrent leave/rejoin on the
        // same (user, community): the registry's with_row + put is not atomic, so
        // without this a join that read the pre-leave row could overwrite a
        // concurrent leave's Left row and re-admit a just-left member.
        let _lifecycle = lock_lifecycle(user, community);
        let now_ns = Timestamp::now().as_nanos();

        // If a prior membership exists and is Left/leaving, this is a rejoin.
        let prior = self
            .membership_registry
            .with_row(user, community, |m| (m.state, m.epoch, m.joined_at_ns));

        let membership = match prior {
            Some((
                MembershipState::Left | MembershipState::LeavingStopForward,
                epoch,
                joined_at_ns,
            )) => {
                let mut m = Membership::joined(
                    user,
                    community,
                    policy,
                    epoch.saturating_add(1),
                    joined_at_ns,
                );
                m.state = MembershipState::Rejoined;
                m
            }
            Some((_, epoch, joined_at_ns)) => {
                // Already active: refresh policy, keep epoch and original join time.
                let mut m = Membership::joined(user, community, policy, epoch, joined_at_ns);
                m.state = if epoch == 0 {
                    MembershipState::Joined
                } else {
                    MembershipState::Rejoined
                };
                m
            }
            None => Membership::joined(user, community, policy, 0, now_ns),
        };

        let epoch = membership.epoch;
        self.persist_membership(&membership)?;
        self.membership_registry.put(membership);
        Ok(epoch)
    }

    /// Leave a community.
    ///
    /// [`LeaveMode::StopForward`](crate::governance::LeaveMode::StopForward)
    /// engages the atomic stop-forward gate (synchronous, sub-microsecond) and
    /// settles the row to `Left`. [`LeaveMode::Purge`](crate::governance::LeaveMode::Purge)
    /// returns `TidalError::invalid_input` until M9p3 wires the rematerialization
    /// engine.
    ///
    /// # Errors
    ///
    /// - `TidalError::NotFound` if no membership exists for `(user, community)`.
    /// - `TidalError::invalid_input` for `LeaveMode::Purge` (deferred to M9p3).
    pub fn leave_community(
        &self,
        user: UserId,
        community: CommunityId,
        mode: crate::governance::LeaveMode,
    ) -> crate::Result<()> {
        self.require_writeable("leave_community")?;
        if matches!(mode, crate::governance::LeaveMode::Purge) {
            return Err(TidalError::invalid_input(
                "LeaveMode::Purge requires retroactive purge (M9p3); use StopForward",
            ));
        }
        // Hold the per-key lifecycle lock for the whole read-modify-write so a
        // concurrent join cannot read the pre-leave row and overwrite the Left
        // row we are about to publish (the lost-update / re-admit window).
        let _lifecycle = lock_lifecycle(user, community);
        let now_ns = Timestamp::now().as_nanos();

        // Durability-before-mutation: build the settled-to-`Left`, gate-engaged
        // snapshot from a READ of the live row WITHOUT touching the live gate,
        // persist it, and only then engage the live in-memory gate + publish the
        // row. Engaging the gate before the durable write (the prior order) left
        // the live stop-forward un-durable on a persist failure: the member would
        // be silently re-admitted on the next open even though the caller saw an
        // error. The gate is an interior-mutability `AtomicU64`, so settling it
        // on the snapshot copy leaves the live row untouched until persist succeeds.
        let snapshot = self.membership_registry.with_row(user, community, |m| {
            let mut snap = m.clone();
            snap.state = MembershipState::Left;
            snap.engage_stop_forward(now_ns);
            snap
        });

        let Some(updated) = snapshot else {
            return Err(TidalError::invalid_input(format!(
                "no membership for user {} in community {community}",
                user.as_u64()
            )));
        };

        // Persist the durable `Left` row first. On failure we return WITHOUT
        // having engaged the live gate, so memory never gets ahead of disk.
        self.persist_membership(&updated)?;

        // Now engage the live gate (so concurrent in-flight contributions stop
        // immediately) and publish the settled row. The published `updated`
        // already carries the engaged gate, so the explicit engage is only a
        // belt-and-suspenders for any reader holding the pre-`put` row.
        self.membership_registry
            .with_row(user, community, |m| m.engage_stop_forward(now_ns));
        self.membership_registry.put(updated);
        Ok(())
    }

    /// Rejoin a community after leaving: opens a new epoch (`epoch + 1`), clears
    /// the stop-forward gate, and sets state `Rejoined`. Returns the new epoch.
    ///
    /// # Errors
    ///
    /// - `TidalError::NotFound` if no prior membership exists.
    pub fn rejoin_community(
        &self,
        user: UserId,
        community: CommunityId,
        policy: SharePolicy,
    ) -> crate::Result<u32> {
        self.require_writeable("rejoin_community")?;
        // Same per-key serialization as join/leave: the read of the prior epoch
        // and the subsequent put must be a single atomic transition.
        let _lifecycle = lock_lifecycle(user, community);

        let prior = self
            .membership_registry
            .with_row(user, community, |m| (m.epoch, m.joined_at_ns));
        let Some((epoch, joined_at_ns)) = prior else {
            return Err(TidalError::invalid_input(format!(
                "no membership for user {} in community {community} to rejoin",
                user.as_u64()
            )));
        };

        let new_epoch = epoch.saturating_add(1);
        let mut membership = Membership::joined(user, community, policy, new_epoch, joined_at_ns);
        membership.state = MembershipState::Rejoined;
        membership.clear_stop_forward();

        self.persist_membership(&membership)?;
        self.membership_registry.put(membership);
        Ok(new_epoch)
    }

    /// The active membership epoch for `(user, community)`, or `None` if the user
    /// is not a member.
    #[must_use]
    pub fn membership_epoch(&self, user: UserId, community: CommunityId) -> Option<u32> {
        self.membership_registry.epoch(user, community)
    }

    /// The current membership state for `(user, community)`, or `None`.
    #[must_use]
    pub fn membership_state(
        &self,
        user: UserId,
        community: CommunityId,
    ) -> Option<MembershipState> {
        self.membership_registry.state(user, community)
    }

    /// Record a community-scoped contribution from `user` to `community`.
    ///
    /// Returns `Ok(true)` if the contribution was recorded (durably logged as a
    /// community-scoped WAL event stamped with the active membership epoch and
    /// share-policy version), or `Ok(false)` if it was **gated** — the user is
    /// not a member, has left / stop-forwarded, or the signal intent is not
    /// share-eligible under the membership's policy. A gated contribution writes
    /// nothing and never touches the local profile.
    ///
    /// The gate is a single `DashMap::get` + atomic load on this synchronous
    /// path; there is no sweeper, scan, or lock.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if the database is not writeable.
    /// - `TidalError::invalid_input` if `weight` is not finite or is negative.
    /// - `TidalError::Schema` if `signal_type` is undefined.
    /// - `TidalError::Durability` if the WAL write fails.
    pub fn signal_for_community(
        &self,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
        user: UserId,
        community: CommunityId,
    ) -> crate::Result<bool> {
        // A direct (non-agent) user write: writer tag is empty.
        self.community_contribute(
            signal_type,
            entity_id,
            weight,
            timestamp,
            user,
            community,
            "",
        )
    }

    /// Shared community-contribution core, attributing the write to `writer_agent`
    /// (`""` for a direct user write). Applies the membership stop-forward gate,
    /// share-eligibility, and governance, then records to the WAL + community
    /// ledger with per-`(user, writer_agent, epoch)` provenance.
    #[allow(clippy::too_many_arguments)]
    pub(super) fn community_contribute(
        &self,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
        user: UserId,
        community: CommunityId,
        writer_agent: &str,
    ) -> crate::Result<bool> {
        self.require_writeable("signal_for_community")?;
        // Same weight admission as the entity signal path: finite AND
        // non-negative (negative engagement uses separate signal types, not
        // negative weights — spec §8), so a community contribution cannot drive
        // a contributor cell's decay score below zero.
        Self::validate_signal_weight(weight)?;

        // <1s STOP-FORWARD / eligibility gate: O(1) atomic read. Drops the
        // contribution (no WAL write) if the member is gated or the intent is
        // not share-eligible. Reads epoch + policy version under the same lock.
        let decision = self.membership_registry.with_row(user, community, |m| {
            (
                m.is_contribution_eligible(signal_type),
                m.epoch,
                m.share_policy.version,
            )
        });

        let Some((eligible, epoch, share_policy_version)) = decision else {
            return Ok(false); // not a member -> not a contributor
        };
        if !eligible {
            return Ok(false); // left / stop-forwarded / intent not allowed
        }

        // M10p1 governance: a community policy can reject out-of-policy intents
        // and clamp the contribution weight into the policy's bounds.
        let effective_weight = match self
            .governance_registry
            .active_at(community, timestamp.as_nanos())
        {
            Some(policy) => {
                if !policy.admits_intent(signal_type) {
                    return Ok(false); // rejected by community governance policy
                }
                policy.clamp_weight(weight)
            }
            None => weight,
        };

        // Durable, shippable community-scoped WAL event (stamped with epoch + spv).
        self.ledger()?.record_signal_scoped(
            signal_type,
            entity_id,
            effective_weight,
            timestamp,
            SignalScope::Community(community),
            share_policy_version,
            epoch,
        )?;

        // In-memory community aggregate, retaining per-contributor attribution so
        // a later purge (M9p3) can remove exactly this user's contributions and
        // rematerialize deterministically.
        //
        // ORDERING (durability-before-mutation, correctly applied): unlike
        // `add_to_collection`/`leave_community`, the durability anchor here is the
        // WAL event ALREADY committed above (`record_signal_scoped` returned Ok,
        // i.e. the event is fsync-durable). `record` below only updates the
        // in-memory aggregate to MIRROR that already-durable event — so memory is
        // never "ahead of disk": it tracks a contribution the WAL has already made
        // durable. `persist_cell` then writes the SECONDARY checkpoint copy (the
        // only copy that carries per-contributor identity, which the WAL event
        // omits). A `persist_cell` failure therefore does NOT mean the contribution
        // was lost: it is still WAL-durable and the checkpoint-restore + WAL replay
        // path reconverges the secondary copy on the next open. We still surface
        // the error so the caller knows the secondary durable copy lagged.
        if let Ok(type_id) = self.community_ledger.resolve_signal_type(signal_type) {
            self.community_ledger.record(
                community,
                entity_id,
                type_id,
                user,
                writer_agent,
                epoch,
                effective_weight,
                timestamp.as_nanos(),
            );

            // ZERO loss window for an accepted contribution: the community
            // aggregate's checkpoint is the SOLE durable copy of per-contributor
            // identity (the WAL community event carries none), so a periodic
            // checkpoint alone would still lose this attribution on a crash before
            // the next tick. The `signal_for_community` rustdoc promises `Ok(true)`
            // means "durably logged" — to honor that for the per-contributor
            // aggregate we synchronously persist (and fsync, see `persist_cell`)
            // the affected cell here BEFORE returning Ok(true). We persist ONLY the
            // one touched row (incremental, O(1) per write) rather than
            // re-checkpointing the entire community keyspace: a full snapshot on
            // every accept is O(total contributions) per write — quadratic
            // ingestion that blows the signal-write budget for any active community
            // (CRITICAL fix). The periodic/lifecycle checkpoint still
            // garbage-collects purged rows.
            if let Some(ref storage) = self.storage
                && let Err(e) = self.community_ledger.persist_cell(
                    storage.items_engine(),
                    community,
                    entity_id,
                    type_id,
                    user,
                    writer_agent,
                    epoch,
                )
            {
                // A failed durability flush must not be reported as a durable
                // accept: surface it so the caller can retry rather than believing
                // the contribution survived a crash.
                return Err(TidalError::Durability(crate::schema::DurabilityError {
                    message: format!("community contribution persist failed: {e}"),
                }));
            }
        }
        Ok(true)
    }

    /// Register a versioned community governance policy.
    ///
    /// Persists the policy under `Tag::GovernancePolicy` and registers it in the
    /// in-memory engine. Versions must be strictly monotonic per community; a
    /// new version supersedes prior ones from its `effective_at_ns`.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if the database is not writeable.
    /// - `TidalError::invalid_input` if the version is not strictly monotonic.
    /// - `TidalError::Storage` on persistence failure.
    pub fn define_community_policy(
        &self,
        policy: crate::governance::GovernancePolicy,
    ) -> crate::Result<()> {
        self.require_writeable("define_community_policy")?;
        // Persist first so a crash between persist and register leaves the policy
        // on disk for the next open.
        if let Some(ref storage) = self.storage {
            let key = encode_key(
                EntityId::new(0),
                Tag::GovernancePolicy,
                &policy.storage_suffix(),
            );
            storage
                .items_engine()
                .put(&key, &policy.to_bytes()?)
                .map_err(TidalError::from)?;
        }
        self.governance_registry
            .register(policy)
            .map_err(|e| TidalError::invalid_input(e.to_string()))
    }

    /// The active governance policy for `community` at the current time, if any.
    #[must_use]
    pub fn active_community_policy(
        &self,
        community: CommunityId,
    ) -> Option<crate::governance::GovernancePolicy> {
        self.governance_registry
            .active_at(community, Timestamp::now().as_nanos())
    }

    /// The active governing policy version for `community`, surfaced into query
    /// metadata ([`PolicyMetadata::policy_version`](crate::query::retrieve::types::PolicyMetadata)).
    #[must_use]
    pub fn governing_policy_version(&self, community: CommunityId) -> Option<u32> {
        self.active_community_policy(community).map(|p| p.version)
    }

    /// The aggregate windowed event count for a community-overlay entity.
    ///
    /// Sums all (non-purged) contributors. `0` if no contributions.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no ledger is wired.
    /// - `TidalError::Schema` if `signal_type` is undefined.
    pub fn read_community_windowed_count(
        &self,
        community: CommunityId,
        entity_id: EntityId,
        signal_type: &str,
        window: crate::schema::Window,
    ) -> crate::Result<u64> {
        self.community_ledger
            .windowed_count(community, entity_id, signal_type, window)
    }

    /// The aggregate decayed score for a community-overlay entity at the current
    /// time. `None` if no contributions.
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type` is undefined.
    pub fn read_community_decay_score(
        &self,
        community: CommunityId,
        entity_id: EntityId,
        signal_type: &str,
        decay_rate_idx: usize,
    ) -> crate::Result<Option<f64>> {
        let now_ns = Timestamp::now().as_nanos();
        self.community_ledger.decay_score_at(
            community,
            entity_id,
            signal_type,
            decay_rate_idx,
            now_ns,
        )
    }

    /// Retroactively purge a user's prior community contributions over an
    /// inclusive `epoch_range`, then deterministically rematerialize the
    /// community aggregate.
    ///
    /// Writes a durable [`PurgeTombstone`](crate::governance::PurgeTombstone)
    /// **before** mutating the aggregate (so a crash mid-purge replays to the
    /// same outcome), removes the user's per-contributor entries, and records a
    /// purge watermark that community queries surface. The user's **local**
    /// profile is never touched. Idempotent: re-purging the same range removes
    /// nothing further and leaves an identical aggregate.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if the database is not writeable.
    /// - `TidalError::Storage` on tombstone persistence failure.
    pub fn purge_prior_contributions(
        &self,
        user: UserId,
        community: CommunityId,
        epoch_range: std::ops::RangeInclusive<u32>,
    ) -> crate::Result<crate::governance::PurgeReceipt> {
        self.require_writeable("purge_prior_contributions")?;
        let watermark_ns = Timestamp::now().as_nanos();
        let tombstone = crate::governance::PurgeTombstone::new(
            user,
            community,
            *epoch_range.start(),
            *epoch_range.end(),
            watermark_ns,
        );

        // Durability-before-mutation: persist the tombstone first.
        self.persist_tombstone(&tombstone)?;

        // Durability is provided by tombstone replay on open; the
        // periodic/lifecycle checkpoint GCs the stale rows. We deliberately do NOT
        // eagerly re-checkpoint here — the agent-purge path (remove_scope.rs) takes
        // the identical stance, so both purge paths share one crash-recovery story.
        let contributions_removed =
            self.community_ledger
                .purge_contributor(community, user, epoch_range);
        self.community_ledger
            .set_purge_watermark(community, watermark_ns);

        Ok(crate::governance::PurgeReceipt {
            contributions_removed,
            watermark_ns,
        })
    }

    /// The latest purge watermark (wall-clock ns) for a community, or `None` if
    /// no purge has occurred. Surfaced to community queries via
    /// [`PolicyMetadata::purge_watermark`](crate::query::retrieve::types::PolicyMetadata).
    #[must_use]
    pub fn community_purge_watermark(&self, community: CommunityId) -> Option<u64> {
        self.community_ledger.purge_watermark(community)
    }

    /// Persist a purge tombstone under `Tag::PurgeTombstone`. No-op in ephemeral mode.
    fn persist_tombstone(&self, t: &crate::governance::PurgeTombstone) -> crate::Result<()> {
        let Some(ref storage) = self.storage else {
            return Ok(());
        };
        let key = encode_key(EntityId::new(0), Tag::PurgeTombstone, &t.storage_suffix());
        storage
            .items_engine()
            .put(&key, &t.to_bytes()?)
            .map_err(TidalError::from)
    }

    /// Persist a membership row under `Tag::Membership`, keyed by
    /// `(user_be8 || community_be8)` so each `(user, community)` row is a stable,
    /// independently-rewritable key. No-op in ephemeral mode.
    fn persist_membership(&self, m: &Membership) -> crate::Result<()> {
        let Some(ref storage) = self.storage else {
            return Ok(());
        };
        let mut suffix = Vec::with_capacity(16);
        suffix.extend_from_slice(&m.user_id.as_u64().to_be_bytes());
        suffix.extend_from_slice(&m.community_id.as_u64().to_be_bytes());
        let key = encode_key(EntityId::new(0), Tag::Membership, &suffix);
        let value = MembershipRegistry::serialize_row(m)?;
        storage
            .items_engine()
            .put(&key, &value)
            .map_err(TidalError::from)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod leave_durability_tests {
    use std::time::Duration;

    use crate::{
        TidalDb,
        governance::{
            LeaveMode, MembershipState, ShareIntent, SharePolicy, UserId, scope::CommunityId,
        },
        schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
    };

    fn schema() -> crate::schema::Schema {
        let mut b = SchemaBuilder::new();
        let _ = b
            .signal(
                "like",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        b.build().unwrap()
    }

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

    /// W7: `leave_community` now persists the gate-engaged `Left` row BEFORE
    /// engaging the live in-memory gate, so memory never gets ahead of disk on a
    /// persist failure. This asserts the published observable result is correct:
    /// the live row is gated (contributions drop) and settled to `Left`, AND the
    /// durable row survives a close + reopen carrying the engaged gate.
    #[test]
    fn leave_persists_gate_before_engaging_live_gate() {
        let dir = tempfile::tempdir().unwrap();
        let (user, community) = (UserId(1), CommunityId(10));
        let item = EntityId::new(7);

        {
            let db = TidalDb::builder()
                .with_data_dir(dir.path())
                .with_schema(schema())
                .open()
                .unwrap();
            db.join_community(user, community, policy()).unwrap();
            let now = Timestamp::now();
            assert!(
                db.signal_for_community("like", item, 1.0, now, user, community)
                    .unwrap(),
                "contribution flows while joined"
            );

            db.leave_community(user, community, LeaveMode::StopForward)
                .unwrap();

            // Live in-memory gate is engaged: the contribution is dropped and the
            // state is settled to Left.
            assert!(
                !db.signal_for_community("like", item, 1.0, Timestamp::now(), user, community)
                    .unwrap(),
                "post-leave contribution must be gated on the live row"
            );
            assert_eq!(
                db.membership_state(user, community),
                Some(MembershipState::Left)
            );
            db.close().unwrap();
        }

        // Reopen: the durable row carries the engaged gate + Left state, so the
        // member stays gated. (If leave had engaged the live gate before
        // persisting and the persist had failed, this would not hold.)
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema())
            .open()
            .unwrap();
        assert_eq!(
            db.membership_state(user, community),
            Some(MembershipState::Left),
            "Left state must survive reopen"
        );
        assert!(
            !db.signal_for_community("like", item, 1.0, Timestamp::now(), user, community)
                .unwrap(),
            "durable gate must keep contributions blocked after reopen"
        );
        db.close().unwrap();
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod lifecycle_lock_tests {
    use super::{LIFECYCLE_LOCK_SHARDS, lifecycle_locks, lock_lifecycle};
    use crate::governance::{UserId, scope::CommunityId};

    /// The lifecycle lock must serialize transitions on the SAME
    /// (user, community) key: while one is held, the same key's shard is
    /// contended (a non-reentrant `try_lock` from this thread returns
    /// `WouldBlock`). This is the mechanism that closes the join/leave
    /// lost-update window. We deliberately do NOT assert that a *distinct* shard
    /// is lockable: the lock table is process-global, so a concurrently-running
    /// test could legitimately hold any other shard — that would make such an
    /// assertion flaky without proving anything about serialization.
    #[test]
    fn lock_serializes_the_same_key() {
        use std::hash::{Hash, Hasher};

        let (u, c) = (UserId(1), CommunityId(10));
        let held = lock_lifecycle(u, c);

        // Recompute the held shard index the same way lock_lifecycle does, then
        // assert that very shard is contended (cannot be re-acquired) while held.
        let held_idx = {
            let mut h = std::collections::hash_map::DefaultHasher::new();
            u.as_u64().hash(&mut h);
            c.as_u64().hash(&mut h);
            (h.finish() as usize) & (LIFECYCLE_LOCK_SHARDS - 1)
        };
        assert!(
            lifecycle_locks()[held_idx].try_lock().is_err(),
            "the held key's shard must be contended while the guard is alive"
        );
        drop(held);
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod join_leave_race_tests {
    use std::{
        sync::{Arc, Barrier},
        time::Duration,
    };

    use crate::{
        TidalDb,
        governance::{
            LeaveMode, MembershipState, ShareIntent, SharePolicy, UserId, scope::CommunityId,
        },
        schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
    };

    fn schema() -> crate::schema::Schema {
        let mut b = SchemaBuilder::new();
        let _ = b
            .signal(
                "like",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        b.build().unwrap()
    }

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

    /// Regression for the join/leave lost-update: a barrier-synchronized
    /// concurrent `leave` (`StopForward`) and `join` must NEVER leave the member
    /// re-admitted at the *leaving* epoch with a cleared gate. With the per-key
    /// lifecycle lock the two transitions serialize, so the final state is always
    /// one of the legal serial outcomes:
    ///
    /// - `Left` (leave won the lock last), or
    /// - `Rejoined` at a *higher* epoch (join observed the Left row and rejoined).
    ///
    /// The illegal outcome the lock forbids is `Joined`/`Rejoined` at epoch 0 with
    /// a cleared gate while a leave also ran — a silently re-admitted member.
    #[test]
    fn concurrent_join_and_leave_never_silently_readmit() {
        let item = EntityId::new(7);
        // A handful of barrier-synced rounds keeps the test fast but maximizes the
        // overlap window each round.
        for round in 0..16u64 {
            let db = Arc::new(
                TidalDb::builder()
                    .ephemeral()
                    .with_schema(schema())
                    .open()
                    .unwrap(),
            );
            let user = UserId(round + 1);
            let community = CommunityId(100 + round);
            db.join_community(user, community, policy()).unwrap();

            let barrier = Arc::new(Barrier::new(2));
            let leaver = {
                let db = Arc::clone(&db);
                let barrier = Arc::clone(&barrier);
                std::thread::spawn(move || {
                    barrier.wait();
                    db.leave_community(user, community, LeaveMode::StopForward)
                        .unwrap();
                })
            };
            let joiner = {
                let db = Arc::clone(&db);
                let barrier = Arc::clone(&barrier);
                std::thread::spawn(move || {
                    barrier.wait();
                    // A re-join attempt racing the leave.
                    let _ = db.join_community(user, community, policy());
                })
            };
            leaver.join().unwrap();
            joiner.join().unwrap();

            let state = db.membership_state(user, community).unwrap();
            let epoch = db.membership_epoch(user, community).unwrap();
            let admits = db
                .signal_for_community("like", item, 1.0, Timestamp::now(), user, community)
                .unwrap();

            match state {
                // Leave settled last: contributions must be gated.
                MembershipState::Left | MembershipState::LeavingStopForward => {
                    assert!(
                        !admits,
                        "round {round}: a Left membership must gate contributions"
                    );
                }
                // Join settled last AFTER observing the leave: it must be a genuine
                // rejoin at a higher epoch, not a re-admit at the leaving epoch.
                MembershipState::Rejoined => {
                    assert!(
                        epoch >= 1,
                        "round {round}: a rejoin after leave must open a new epoch (>0), \
                         got epoch {epoch} — that would be a silent re-admit"
                    );
                }
                // The only way to stay Joined at epoch 0 is if the join's
                // read-modify-write completed entirely before leave engaged the
                // gate (serialized, join-then-leave is impossible here since both
                // started behind the barrier and leave always settles to Left).
                // Reaching Joined means leave never ran its mutation under the
                // lock — which the serialization forbids.
                MembershipState::Joined => {
                    panic!(
                        "round {round}: ended in Joined@{epoch} despite a concurrent leave — \
                         the lifecycle lock must prevent a join from overwriting the Left row"
                    );
                }
            }
        }
    }
}
