//! FoF Layer 2: post-side construction + verification for FoF-gated
//! comments. See `docs/fof-spec/layer-2-mode2-fof-comments.md` for the
//! wire shape and threat model.
//!
//! This module owns:
//!  - **Author publish path** ([`build_fof_comment_gating`]): seal wrap
//!    slots, generate per-V_x signing keypairs, bucket-pad, shuffle.
//!  - **Reader/commenter unlock path** ([`find_unlock_for_post`],
//!    [`build_fof_comment`]): trial-decrypt slots with any held V_x;
//!    if a slot opens, derive priv_x + comments-CEK, encrypt the
//!    comment body, sign with priv_x, attach pub_x_index.
//!
//! The CDN four-check verification path and revocation handling live
//! in sibling modules (added in subsequent slices).

use anyhow::Result;
use ed25519_dalek::SigningKey;
use rand::seq::SliceRandom;
use rand::RngCore;

use crate::crypto::{seal_wrap_slot, SealedWrapSlot};
use crate::storage::Storage;
use crate::types::{FoFCommentGating, NodeId, WrapSlot};

/// Build the `FoFCommentGating` block for a post about to be published
/// under `CommentPermission::FriendsOfFriends`. The author's keyring
/// (own current `V_me` + every distinct received `V_x`) drives the
/// real-slot set; dummies pad the count to the next bucket.
///
/// Returns `None` if the author has no current `V_me` set — every
/// persona is supposed to have one (auto-generated on creation in
/// Layer 1), but this gracefully no-ops if not.
///
/// Side effect: this function is pure; no storage writes. The caller
/// owns persisting the resulting Post.
pub fn build_fof_comment_gating(
    storage: &Storage,
    author_persona_id: &NodeId,
) -> Result<Option<FoFCommentGatingBuilt>> {
    // Gather the author's keyring with provenance: (V_x, owner, epoch).
    // The author's own V_me appears with owner=author_persona_id.
    let Some((own_epoch, own_v_me)) = storage.current_own_vouch_key(author_persona_id)? else {
        return Ok(None);
    };
    let received = storage.list_received_vouch_keys(author_persona_id)?;

    // Dedup at the V_x byte level — keep the first sighting (which is
    // own_v_me for the author's slot, then received keys in storage
    // order). Per Layer 3 spec: one slot per unique V_x.
    let mut tagged_keys: Vec<([u8; 32], NodeId, u32)> =
        Vec::with_capacity(1 + received.len());
    tagged_keys.push((own_v_me, *author_persona_id, own_epoch));
    for (owner, epoch, key) in &received {
        if !tagged_keys.iter().any(|(existing, _, _)| existing == key) {
            tagged_keys.push((*key, *owner, *epoch));
        }
    }

    // Generate the per-post CEK + slot_binder_nonce.
    let mut cek = [0u8; 32];
    rand::rng().fill_bytes(&mut cek);
    let mut slot_binder_nonce = [0u8; 32];
    rand::rng().fill_bytes(&mut slot_binder_nonce);

    // Per real V_x: generate (priv_x, pub_x) freshly per spec (Layer 2
    // resolved decision — per-post keypair generation). Then seal the
    // slot.
    //
    // We carry a `kind` tag through the shuffle so we can recover
    // (slot_index, owner, epoch, pub_x) afterward for provenance.
    enum EntryKind {
        Real { v_x_owner: NodeId, v_x_epoch: u32 },
        Dummy,
    }
    let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len());
    for (v_x, owner, epoch) in &tagged_keys {
        let mut seed = [0u8; 32];
        rand::rng().fill_bytes(&mut seed);
        let signing_key = SigningKey::from_bytes(&seed);
        let pub_x = *signing_key.verifying_key().as_bytes();

        let sealed: SealedWrapSlot = seal_wrap_slot(v_x, &slot_binder_nonce, &cek, &seed)?;
        let slot = WrapSlot {
            prefilter_tag: sealed.prefilter_tag,
            read_ciphertext: sealed.read_ciphertext,
            sign_ciphertext: sealed.sign_ciphertext,
        };
        entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot));
    }

    // Pad to bucket with dummies.
    let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
    let mut rng = rand::rng();
    while entries.len() < bucket {
        let mut dummy_pub_x = [0u8; 32];
        rng.fill_bytes(&mut dummy_pub_x);
        let mut dummy_prefilter = [0u8; 2];
        rng.fill_bytes(&mut dummy_prefilter);
        let mut dummy_read = vec![0u8; 48];
        rng.fill_bytes(&mut dummy_read);
        let mut dummy_sign = vec![0u8; 48];
        rng.fill_bytes(&mut dummy_sign);
        entries.push((
            EntryKind::Dummy,
            dummy_pub_x,
            WrapSlot {
                prefilter_tag: dummy_prefilter,
                read_ciphertext: dummy_read,
                sign_ciphertext: dummy_sign,
            },
        ));
    }

    // Shuffle so real and dummy positions are indistinguishable.
    entries.shuffle(&mut rng);

    let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len());
    let mut wrap_slots: Vec<WrapSlot> = Vec::with_capacity(entries.len());
    let mut real_slot_provenance: Vec<RealSlotProvenance> = Vec::new();
    for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() {
        if let EntryKind::Real { v_x_owner, v_x_epoch } = kind {
            real_slot_provenance.push(RealSlotProvenance {
                slot_index: idx as u32,
                v_x_owner,
                v_x_epoch,
                pub_x,
            });
        }
        pub_post_set.push(pub_x);
        wrap_slots.push(slot);
    }

    let gating = FoFCommentGating {
        slot_binder_nonce,
        pub_post_set,
        wrap_slots,
        revocation_list: Vec::new(),
    };

    Ok(Some(FoFCommentGatingBuilt {
        gating,
        cek,
        slot_binder_nonce,
        real_slot_provenance,
    }))
}

/// Output of [`build_fof_comment_gating`]. The gating block goes into
/// `Post.fof_gating`; the side outputs are author-local state the
/// caller persists (CEK cache, `own_post_slot_provenance` for cascade
/// revocations).
#[derive(Debug, Clone)]
pub struct FoFCommentGatingBuilt {
    pub gating: FoFCommentGating,
    /// The post's body/comments encryption key. Authors keep this
    /// locally keyed by `post_id` so they can read/decrypt without
    /// needing to unwrap a slot themselves.
    pub cek: [u8; 32],
    /// Same nonce as `gating.slot_binder_nonce`; mirrored here for
    /// callers who want it without reaching into the gating struct.
    pub slot_binder_nonce: [u8; 32],
    /// FoF Layer 4 provenance: which V_x sealed which slot. Real
    /// slots only (dummies are excluded). Caller persists into
    /// `own_post_slot_provenance` for later cascade revocations.
    /// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x).
    pub real_slot_provenance: Vec<RealSlotProvenance>,
}

/// One entry per real (non-dummy) slot in a published FoF post.
#[derive(Debug, Clone)]
pub struct RealSlotProvenance {
    pub slot_index: u32,
    /// Persona who issued the V_x this slot was sealed under. For the
    /// author's own slot this is the author themselves.
    pub v_x_owner: NodeId,
    /// V_x epoch — important for cascade revocation when an old
    /// epoch is retired.
    pub v_x_epoch: u32,
    pub pub_x: [u8; 32],
}

// --- Reader / commenter side ---

/// FoF Layer 2: a persona's successful unlock of a gated post.
/// Carries everything the persona needs to read encrypted comments AND
/// author new ones.
#[derive(Debug, Clone)]
pub struct PostUnlock {
    /// Which persona unlocked the post (the V_x that matched belongs to
    /// this persona's keyring — owned `V_me` or received).
    pub persona_id: NodeId,
    /// Index into `pub_post_set` / `wrap_slots` of the slot that
    /// unlocked. Comments author by this persona will set
    /// `InlineComment.pub_x_index` to this.
    pub slot_index: u32,
    /// Per-post shared CEK (unwrapped from the slot's read part).
    pub cek: [u8; 32],
    /// Ed25519 signing seed for the per-V_x keypair admitted to this
    /// post (unwrapped from the slot's sign part). Used to sign
    /// `group_sig` on FoF comments.
    pub priv_x_seed: [u8; 32],
}

/// Trial-decrypt the post's `fof_gating.wrap_slots` against every
/// persona on this device. Returns the first successful unlock found,
/// or `None` if no held V_x matches.
///
/// FoF Layer 5: consults `vouch_unlock_cache` first to skip directly
/// to the V_x that worked last time for this `(persona, author)` pair.
/// Falls back to a full scan on miss. On success, updates the cache;
/// on failure, queues the post in `vouch_unreadable_posts` for later
/// retry when a new V_x arrives.
pub fn find_unlock_for_post(
    storage: &Storage,
    post: &crate::types::Post,
) -> Result<Option<PostUnlock>> {
    let Some(gating) = post.fof_gating.as_ref() else { return Ok(None); };
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    let post_id_for_cache = crate::content::compute_post_id(post);
    let personas = storage.list_posting_identities()?;

    // Fast path: try the cached winning V_x per (persona, author).
    for persona in &personas {
        let Some((owner, epoch)) = storage.lookup_unlock_cache(&persona.node_id, &post.author)? else {
            continue;
        };
        let v_x = if owner == persona.node_id {
            storage.list_own_vouch_keys(&persona.node_id)?
                .into_iter()
                .find(|(e, _)| *e == epoch)
                .map(|(_, k)| k)
        } else {
            storage.list_received_vouch_keys(&persona.node_id)?
                .into_iter()
                .find(|(o, e, _)| *o == owner && *e == epoch)
                .map(|(_, _, k)| k)
        };
        if let Some(v_x) = v_x {
            if let Some(unlock) = try_unlock_with_v_x(gating, &v_x, &persona.node_id) {
                storage.record_unlock_hit(
                    &persona.node_id, &post.author, &owner, epoch, now_ms,
                )?;
                let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache);
                return Ok(Some(unlock));
            }
            // Cache stale (e.g., post was key-burned). Fall through to
            // full scan; next success overwrites the cache.
        }
    }

    // Full scan path.
    for persona in &personas {
        let mut keyring: Vec<([u8; 32], NodeId, u32)> = Vec::new();
        if let Some((epoch, own_key)) = storage.current_own_vouch_key(&persona.node_id)? {
            keyring.push((own_key, persona.node_id, epoch));
        }
        for (owner, epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? {
            keyring.push((key, owner, epoch));
        }
        for (v_x, owner, epoch) in &keyring {
            if let Some(unlock) = try_unlock_with_v_x(gating, v_x, &persona.node_id) {
                storage.record_unlock_hit(
                    &persona.node_id, &post.author, owner, *epoch, now_ms,
                )?;
                let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache);
                return Ok(Some(unlock));
            }
        }
        // No held V_x unlocks this post for this persona — queue for
        // retry when a new V_x arrives in the keyring.
        let _ = storage.record_unreadable_post(
            &persona.node_id, &post_id_for_cache, &post.author, now_ms,
        );
    }
    Ok(None)
}

/// Maximum posts processed per `sweep_unreadable_on_new_v_x` call.
/// Bounds lock-hold time. Unprocessed entries remain queued and will
/// be tried on the next V_x arrival or via a periodic background
/// sweep (future Layer 5+ work).
const MAX_SWEEP_PER_CALL: usize = 256;

/// FoF Layer 5: when a persona acquires a new V_x, walk the
/// unreadable-posts queue and re-attempt unlock. The new V_x can
/// unlock posts authored by anyone (the author may hold the V_x's
/// owner as one of their vouches). Successful unlocks populate
/// `vouch_unlock_cache` + clear the queue entry as a side effect.
///
/// Bounded to `MAX_SWEEP_PER_CALL` posts per invocation to cap
/// lock-hold time and prevent spam-grant-triggered DoS. Remaining
/// entries are processed on subsequent V_x arrivals.
pub fn sweep_unreadable_on_new_v_x(
    storage: &Storage,
    holder_persona_id: &NodeId,
    _v_x_owner: &NodeId,
) -> Result<usize> {
    let post_ids = storage.list_all_unreadable_posts(holder_persona_id)?;
    let mut unlocked = 0usize;
    for post_id in post_ids.into_iter().take(MAX_SWEEP_PER_CALL) {
        let Some((post, _vis)) = storage.get_post_with_visibility(&post_id)? else {
            let _ = storage.clear_unreadable_post(holder_persona_id, &post_id);
            continue;
        };
        // find_unlock_for_post records the cache hit + clears the
        // unreadable entry as a side effect on success.
        if find_unlock_for_post(storage, &post)?.is_some() {
            unlocked += 1;
        }
    }
    Ok(unlocked)
}

/// Inner helper: prefilter + AEAD-open against a single V_x.
fn try_unlock_with_v_x(
    gating: &crate::types::FoFCommentGating,
    v_x: &[u8; 32],
    persona_id: &NodeId,
) -> Option<PostUnlock> {
    let prefilter = crate::crypto::wrap_slot_prefilter_tag(v_x, &gating.slot_binder_nonce);
    for (idx, slot) in gating.wrap_slots.iter().enumerate() {
        if slot.prefilter_tag != prefilter {
            continue;
        }
        if let Some(opened) = crate::crypto::open_wrap_slot(
            v_x,
            &gating.slot_binder_nonce,
            &slot.read_ciphertext,
            &slot.sign_ciphertext,
        ) {
            return Some(PostUnlock {
                persona_id: *persona_id,
                slot_index: idx as u32,
                cek: opened.cek,
                priv_x_seed: opened.priv_x_seed,
            });
        }
    }
    None
}

/// FoF Layer 2: inner plaintext encrypted under CEK_comments. Wrapped
/// inside [`crate::types::InlineComment::encrypted_payload`].
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FoFCommentPayload {
    /// User-visible comment body.
    pub body: String,
    /// Optional reply parent.
    #[serde(default)]
    pub parent_comment_id: Option<[u8; 32]>,
    /// HMAC(V_x, post_id || comment_hash)[:16B] — author-side
    /// attribution to a specific voucher-chain. Computed by the
    /// commenter; not yet enforced in this commit.
    #[serde(default)]
    pub vouch_mac: Option<[u8; 16]>,
}

/// Build a Mode 2 / Mode 1 FoF comment on a parent post. The post must
/// have `fof_gating = Some`; the caller must already hold a successful
/// `PostUnlock` for it (typically from [`find_unlock_for_post`]).
///
/// Produces an `InlineComment` with empty `content` (the body lives
/// encrypted in `encrypted_payload`) + `pub_x_index` + `group_sig` +
/// `encrypted_payload`. The conventional `signature` field carries the
/// commenter's identity-key signature over the existing payload (so
/// non-FoF nodes can still verify identity without unwrapping the slot).
pub fn build_fof_comment(
    parent_post_id: &[u8; 32],
    unlock: &PostUnlock,
    slot_binder_nonce: &[u8; 32],
    commenter_id: &NodeId,
    commenter_secret: &[u8; 32],
    body: &str,
    parent_comment_id: Option<[u8; 32]>,
    now_ms: u64,
) -> Result<crate::types::InlineComment> {
    use ed25519_dalek::{Signer, SigningKey};

    // Encrypt the comment body under CEK_comments derived from the
    // post's CEK + slot_binder_nonce.
    let cek_comments = crate::crypto::derive_cek_comments(&unlock.cek, slot_binder_nonce);
    let payload = FoFCommentPayload {
        body: body.to_string(),
        parent_comment_id,
        vouch_mac: None,
    };
    let plaintext = serde_json::to_vec(&payload)
        .map_err(|e| anyhow::anyhow!("serialize fof comment payload: {}", e))?;
    let encrypted = crate::crypto::encrypt_bytes_with_cek(&plaintext, &cek_comments)?;

    // Sign (encrypted || post_id || pub_x_index_le) with priv_x.
    let priv_x_signer = SigningKey::from_bytes(&unlock.priv_x_seed);
    let mut to_sign = Vec::with_capacity(encrypted.len() + 32 + 4);
    to_sign.extend_from_slice(&encrypted);
    to_sign.extend_from_slice(parent_post_id);
    to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes());
    let group_sig = priv_x_signer.sign(&to_sign).to_bytes().to_vec();

    // Conventional InlineComment.signature: commenter's identity-key
    // signature over the existing comment_signature tuple. We use empty
    // content here (the body lives in encrypted_payload). Existing
    // non-FoF receivers can still verify the identity sig and tombstone
    // logic continues to work.
    let identity_signature = crate::crypto::sign_comment(
        commenter_secret,
        commenter_id,
        parent_post_id,
        "",
        now_ms,
        None,
    );

    Ok(crate::types::InlineComment {
        author: *commenter_id,
        post_id: *parent_post_id,
        content: String::new(),
        timestamp_ms: now_ms,
        signature: identity_signature,
        deleted_at: None,
        ref_post_id: None,
        pub_x_index: Some(unlock.slot_index),
        group_sig: Some(group_sig),
        encrypted_payload: Some(encrypted),
    })
}

/// Verify the `group_sig` on an incoming FoF comment against the post's
/// `pub_post_set`. Used by the CDN four-check accept rule (next slice).
/// Returns `true` iff the comment carries a valid Ed25519 signature
/// under `pub_post_set[pub_x_index]` over
/// (encrypted_payload || post_id || pub_x_index_le).
pub fn verify_fof_group_sig(
    comment: &crate::types::InlineComment,
    gating: &FoFCommentGating,
) -> bool {
    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
    let Some(pub_x_index) = comment.pub_x_index else { return false; };
    let Some(group_sig) = comment.group_sig.as_ref() else { return false; };
    let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; };
    let idx = pub_x_index as usize;
    if idx >= gating.pub_post_set.len() { return false; }
    if group_sig.len() != 64 { return false; }
    let pub_x = &gating.pub_post_set[idx];
    let Ok(verifying_key) = VerifyingKey::from_bytes(pub_x) else { return false; };
    let sig_bytes: [u8; 64] = match group_sig.as_slice().try_into() {
        Ok(b) => b, Err(_) => return false,
    };
    let sig = Signature::from_bytes(&sig_bytes);
    let mut to_verify = Vec::with_capacity(encrypted_payload.len() + 32 + 4);
    to_verify.extend_from_slice(encrypted_payload);
    to_verify.extend_from_slice(&comment.post_id);
    to_verify.extend_from_slice(&pub_x_index.to_le_bytes());
    verifying_key.verify(&to_verify, &sig).is_ok()
}

/// Decrypt the `encrypted_payload` of an FoF comment back to its
/// plaintext body / vouch_mac / parent_comment_id, using the CEK
/// recovered via [`find_unlock_for_post`].
pub fn decrypt_fof_comment_payload(
    comment: &crate::types::InlineComment,
    cek: &[u8; 32],
    slot_binder_nonce: &[u8; 32],
) -> Result<FoFCommentPayload> {
    let encrypted = comment.encrypted_payload.as_ref()
        .ok_or_else(|| anyhow::anyhow!("comment has no encrypted_payload"))?;
    let cek_comments = crate::crypto::derive_cek_comments(cek, slot_binder_nonce);
    let plaintext = crate::crypto::decrypt_bytes_with_cek(encrypted, &cek_comments)?;
    serde_json::from_slice(&plaintext)
        .map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e))
}

// --- Wire-shape validation for incoming posts (DoS hardening) ---
//
// Called from control::receive_post before any storage write. Rejects
// malformed FoF gating blocks so they never enter storage and get
// re-propagated via neighbor-manifest diffs.

/// Maximum allowed wrap_slots / pub_post_set entries on an incoming
/// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256;
/// at a 4096-vouchee max realistic graph that's ~4224. Round up for
/// headroom; anything larger is presumed attacker-shaped.
const MAX_FOF_WRAP_SLOTS: usize = 8192;

/// Maximum allowed revocation_list entries in a t=0 published gating
/// block. Initial publish should have an EMPTY list; receivers
/// accumulate revocations via diffs into the live fof_revocations
/// table. A non-empty list on first publish is suspicious but not
/// strictly invalid — bound it generously.
const MAX_FOF_REVOCATION_LIST: usize = 4096;

/// Validate a FoF gating block on receive. Rejects:
///  - wrap_slots / pub_post_set length mismatch (indexing invariant)
///  - bucket-size violation (DoS bound)
///  - wrong WrapSlot ciphertext sizes (always 48 bytes today)
///  - oversized revocation_list (DoS bound)
///
/// Sound caller pattern (control::receive_post):
///   - Visibility == FoFClosed implies post.fof_gating MUST be Some.
///   - Any post with fof_gating Some passes this check.
pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> {
    // Invariant: FoFClosed visibility implies Some gating.
    // (No way to recover the body without it.)
    // The visibility is checked by the caller; this validates the
    // gating shape when it's present.
    let Some(gating) = post.fof_gating.as_ref() else {
        return Ok(());
    };

    // 1:1 invariant: every wrap_slot has a matching pub_post_set entry
    // so pub_x_index lookups always succeed within bounds.
    if gating.wrap_slots.len() != gating.pub_post_set.len() {
        anyhow::bail!(
            "FoF wrap_slots/pub_post_set length mismatch: {} vs {}",
            gating.wrap_slots.len(),
            gating.pub_post_set.len(),
        );
    }

    // Bucket-size cap — bounds memory + scan cost.
    if gating.wrap_slots.len() > MAX_FOF_WRAP_SLOTS {
        anyhow::bail!(
            "FoF wrap_slots oversized: {} > {} (DoS cap)",
            gating.wrap_slots.len(),
            MAX_FOF_WRAP_SLOTS,
        );
    }

    // Per-slot field-size invariants. seal_wrap_slot always produces
    // 48-byte ciphertexts (32B sealed plaintext + 16B AEAD tag); any
    // other size is malformed and shouldn't tie up AEAD attempts.
    for (i, slot) in gating.wrap_slots.iter().enumerate() {
        if slot.read_ciphertext.len() != 48 || slot.sign_ciphertext.len() != 48 {
            anyhow::bail!(
                "FoF wrap_slot {} has wrong ciphertext sizes: read={} sign={} (must both be 48)",
                i,
                slot.read_ciphertext.len(),
                slot.sign_ciphertext.len(),
            );
        }
    }

    // Bound revocation_list size — revocations should arrive as diffs,
    // not be stuffed into the t=0 publish. Cap generously.
    if gating.revocation_list.len() > MAX_FOF_REVOCATION_LIST {
        anyhow::bail!(
            "FoF revocation_list oversized: {} > {} (DoS cap)",
            gating.revocation_list.len(),
            MAX_FOF_REVOCATION_LIST,
        );
    }

    Ok(())
}

/// Companion check: enforce the visibility-implies-gating invariant.
/// Called from `control::receive_post` after the gating-shape check.
pub fn validate_fof_closed_has_gating(
    post: &crate::types::Post,
    visibility: &crate::types::PostVisibility,
) -> Result<()> {
    if matches!(visibility, crate::types::PostVisibility::FoFClosed)
        && post.fof_gating.is_none()
    {
        anyhow::bail!("FoFClosed visibility requires a fof_gating block");
    }
    Ok(())
}

// --- Mode 1 (FoFClosed) body encryption ---
//
// Mode 1 reuses Layer 2's wrap_slots and CEK. The only addition is
// that the post body is itself encrypted under that CEK before
// publish; readers who can unlock a slot (recovering CEK) decrypt the
// body too. Non-FoF readers see only the ciphertext; they can still
// store + propagate the post as a CDN host without decrypting.

/// FoF Layer 3: encrypt a post body under the gating CEK, padded to a
/// bucket per the spec. Output is `body_bucket_padding_nonce(12) ||
/// real_len_u32_le || ciphertext_with_tag(body+pad+16)` so the reader
/// can recover the original byte length after decryption.
pub fn encrypt_fof_body(
    body: &str,
    cek: &[u8; 32],
    slot_binder_nonce: &[u8; 32],
) -> Result<Vec<u8>> {
    use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce};
    use rand::RngCore;

    let real = body.as_bytes();
    // Pre-tag size we want to encrypt = 4-byte length prefix + body + padding.
    let prefixed_len = 4 + real.len();
    let bucket = next_body_size_bucket(prefixed_len);
    let mut plaintext = Vec::with_capacity(bucket);
    plaintext.extend_from_slice(&(real.len() as u32).to_le_bytes());
    plaintext.extend_from_slice(real);
    // Pad with random bytes (NOT zeros — zeros would be a small
    // distinguisher under known-plaintext but doesn't matter under
    // AEAD; random is defense-in-depth.)
    let mut pad = vec![0u8; bucket.saturating_sub(plaintext.len())];
    rand::rng().fill_bytes(&mut pad);
    plaintext.extend_from_slice(&pad);

    // Derive a per-body nonce-context key so we don't reuse the slot
    // AEAD nonces. blake3-derive over (cek, slot_binder_nonce) gives
    // us a stable seed; we then prepend a fresh random 12B nonce.
    let cipher = ChaCha20Poly1305::new_from_slice(cek)
        .map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?;
    let mut nonce_bytes = [0u8; 12];
    rand::rng().fill_bytes(&mut nonce_bytes);
    // AAD = slot_binder_nonce. Binds body decrypt to the post's gating
    // (an attacker who steals the body ciphertext + CEK can't decrypt
    // against a different post's wrap slots).
    let ciphertext = cipher
        .encrypt(
            Nonce::from_slice(&nonce_bytes),
            chacha20poly1305::aead::Payload {
                msg: &plaintext,
                aad: slot_binder_nonce,
            },
        )
        .map_err(|e| anyhow::anyhow!("body encrypt: {}", e))?;

    let mut out = Vec::with_capacity(12 + ciphertext.len());
    out.extend_from_slice(&nonce_bytes);
    out.extend_from_slice(&ciphertext);
    Ok(out)
}

/// FoF Layer 3: decrypt a body sealed by [`encrypt_fof_body`]. Returns
/// the original `String` body (padding stripped via the 4-byte length
/// prefix).
pub fn decrypt_fof_body(
    encrypted: &[u8],
    cek: &[u8; 32],
    slot_binder_nonce: &[u8; 32],
) -> Result<String> {
    use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce};

    if encrypted.len() < 12 + 16 {
        anyhow::bail!("encrypted body too short");
    }
    let nonce = Nonce::from_slice(&encrypted[..12]);
    let cipher = ChaCha20Poly1305::new_from_slice(cek)
        .map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?;
    let plaintext = cipher
        .decrypt(
            nonce,
            chacha20poly1305::aead::Payload {
                msg: &encrypted[12..],
                aad: slot_binder_nonce,
            },
        )
        .map_err(|e| anyhow::anyhow!("body decrypt: {}", e))?;
    if plaintext.len() < 4 {
        anyhow::bail!("body plaintext missing length prefix");
    }
    let real_len = u32::from_le_bytes(plaintext[..4].try_into().unwrap()) as usize;
    if 4 + real_len > plaintext.len() {
        anyhow::bail!("body length prefix overruns plaintext");
    }
    let body_bytes = &plaintext[4..4 + real_len];
    String::from_utf8(body_bytes.to_vec())
        .map_err(|e| anyhow::anyhow!("body not valid UTF-8: {}", e))
}

/// FoF Layer 3 body-size bucket rule.
/// Power-of-2 from a minimum of 1 KiB up to 256 KiB, then linear
/// +256 KiB steps above. Hides body length within meaningful brackets.
pub(crate) fn next_body_size_bucket(real: usize) -> usize {
    const MIN_BUCKET: usize = 1024;
    const POW2_CEIL: usize = 256 * 1024;
    const LINEAR_STEP: usize = 256 * 1024;
    if real <= MIN_BUCKET { return MIN_BUCKET; }
    if real <= POW2_CEIL {
        let mut b = MIN_BUCKET;
        while b < real { b *= 2; }
        return b;
    }
    // 256K, 512K, 768K, ...
    let above = real - POW2_CEIL;
    let steps = (above + LINEAR_STEP - 1) / LINEAR_STEP;
    POW2_CEIL + steps * LINEAR_STEP
}

// --- Revocation: sign + verify + apply ---

/// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`.
/// Constructed identically on both ends so the verify is deterministic.
fn fof_revocation_signing_bytes(
    post_id: &[u8; 32],
    revoked_pub_x: &[u8; 32],
    revoked_at_ms: u64,
    reason_code: u8,
) -> [u8; 32 + 32 + 8 + 1] {
    let mut out = [0u8; 32 + 32 + 8 + 1];
    out[..32].copy_from_slice(post_id);
    out[32..64].copy_from_slice(revoked_pub_x);
    out[64..72].copy_from_slice(&revoked_at_ms.to_le_bytes());
    out[72] = reason_code;
    out
}

/// Author-side: sign a revocation entry with the post author's
/// identity secret. Returns the 64-byte Ed25519 signature.
pub fn sign_fof_revocation(
    author_secret: &[u8; 32],
    post_id: &[u8; 32],
    revoked_pub_x: &[u8; 32],
    revoked_at_ms: u64,
    reason_code: u8,
) -> Vec<u8> {
    use ed25519_dalek::{Signer, SigningKey};
    let signing_key = SigningKey::from_bytes(author_secret);
    let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code);
    signing_key.sign(&bytes).to_bytes().to_vec()
}

/// CDN-side: verify a revocation entry's author_sig against the post
/// author's public key. Returns `false` on any shape/key/signature
/// failure.
pub fn verify_fof_revocation(
    post_author: &NodeId,
    post_id: &[u8; 32],
    revoked_pub_x: &[u8; 32],
    revoked_at_ms: u64,
    reason_code: u8,
    author_sig: &[u8],
) -> bool {
    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
    if author_sig.len() != 64 { return false; }
    let sig_bytes: [u8; 64] = match author_sig.try_into() {
        Ok(b) => b, Err(_) => return false,
    };
    let sig = Signature::from_bytes(&sig_bytes);
    let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; };
    let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code);
    verifying_key.verify(&bytes, &sig).is_ok()
}

// --- Access-grant: sign + verify + apply ---

/// Bytes covered by a `BlobHeaderDiffOp::FoFAccessGrant.author_sig`.
/// Wrap-slot is canonicalized by serializing its three fields in order:
/// prefilter_tag || read_ciphertext || sign_ciphertext.
fn fof_access_grant_signing_bytes(
    post_id: &[u8; 32],
    new_pub_x: &[u8; 32],
    new_wrap_slot: &crate::types::WrapSlot,
    granted_at_ms: u64,
) -> Vec<u8> {
    let mut out = Vec::with_capacity(32 + 32 + 2 + 48 + 48 + 8);
    out.extend_from_slice(post_id);
    out.extend_from_slice(new_pub_x);
    out.extend_from_slice(&new_wrap_slot.prefilter_tag);
    out.extend_from_slice(&new_wrap_slot.read_ciphertext);
    out.extend_from_slice(&new_wrap_slot.sign_ciphertext);
    out.extend_from_slice(&granted_at_ms.to_le_bytes());
    out
}

/// Author-side: sign an access-grant entry.
pub fn sign_fof_access_grant(
    author_secret: &[u8; 32],
    post_id: &[u8; 32],
    new_pub_x: &[u8; 32],
    new_wrap_slot: &crate::types::WrapSlot,
    granted_at_ms: u64,
) -> Vec<u8> {
    use ed25519_dalek::{Signer, SigningKey};
    let signing_key = SigningKey::from_bytes(author_secret);
    let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms);
    signing_key.sign(&bytes).to_bytes().to_vec()
}

/// CDN-side: verify an access-grant entry's author_sig.
pub fn verify_fof_access_grant(
    post_author: &NodeId,
    post_id: &[u8; 32],
    new_pub_x: &[u8; 32],
    new_wrap_slot: &crate::types::WrapSlot,
    granted_at_ms: u64,
    author_sig: &[u8],
) -> bool {
    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
    if author_sig.len() != 64 { return false; }
    let sig_bytes: [u8; 64] = match author_sig.try_into() {
        Ok(b) => b, Err(_) => return false,
    };
    let sig = Signature::from_bytes(&sig_bytes);
    let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; };
    let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms);
    verifying_key.verify(&bytes, &sig).is_ok()
}

// --- Key burn: sign + verify + apply (Layer 4) ---

fn fof_key_burn_signing_bytes(
    post_id: &[u8; 32],
    slot_index: u32,
    new_pub_x: &[u8; 32],
    new_wrap_slot: &crate::types::WrapSlot,
    burned_at_ms: u64,
) -> Vec<u8> {
    let mut out = Vec::with_capacity(32 + 4 + 32 + 2 + 48 + 48 + 8);
    out.extend_from_slice(post_id);
    out.extend_from_slice(&slot_index.to_le_bytes());
    out.extend_from_slice(new_pub_x);
    out.extend_from_slice(&new_wrap_slot.prefilter_tag);
    out.extend_from_slice(&new_wrap_slot.read_ciphertext);
    out.extend_from_slice(&new_wrap_slot.sign_ciphertext);
    out.extend_from_slice(&burned_at_ms.to_le_bytes());
    out
}

pub fn sign_fof_key_burn(
    author_secret: &[u8; 32],
    post_id: &[u8; 32],
    slot_index: u32,
    new_pub_x: &[u8; 32],
    new_wrap_slot: &crate::types::WrapSlot,
    burned_at_ms: u64,
) -> Vec<u8> {
    use ed25519_dalek::{Signer, SigningKey};
    let signing_key = SigningKey::from_bytes(author_secret);
    let bytes = fof_key_burn_signing_bytes(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms);
    signing_key.sign(&bytes).to_bytes().to_vec()
}

pub fn verify_fof_key_burn(
    post_author: &NodeId,
    post_id: &[u8; 32],
    slot_index: u32,
    new_pub_x: &[u8; 32],
    new_wrap_slot: &crate::types::WrapSlot,
    burned_at_ms: u64,
    author_sig: &[u8],
) -> bool {
    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
    if author_sig.len() != 64 { return false; }
    let sig_bytes: [u8; 64] = match author_sig.try_into() {
        Ok(b) => b, Err(_) => return false,
    };
    let sig = Signature::from_bytes(&sig_bytes);
    let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; };
    let bytes = fof_key_burn_signing_bytes(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms);
    verifying_key.verify(&bytes, &sig).is_ok()
}

/// Apply a verified key-burn to local storage. Replaces the wrap_slot
/// + pub_x at the indicated slot in the stored post's fof_gating. The
/// old key's holders can no longer decrypt this post via this slot
/// (locally-cached plaintext on already-read devices is out of scope).
///
/// `burned_at_ms` enforces monotonic ordering: older signed key-burn
/// diffs cannot revert newer state.
pub fn apply_fof_key_burn_locally(
    storage: &Storage,
    post_id: &[u8; 32],
    slot_index: u32,
    new_pub_x: &[u8; 32],
    new_wrap_slot: &crate::types::WrapSlot,
    burned_at_ms: u64,
) -> Result<bool> {
    storage.replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms)
}

/// Apply a verified access-grant to local storage. Appends the new
/// (pub_x, wrap_slot) at the tail of the stored post's fof_gating.
/// Idempotent on `(post_id, new_pub_x)`. Must only be called after
/// `verify_fof_access_grant` returns true.
///
/// Refuses to apply if `new_pub_x` is already in the post's
/// revocation_list (prevents accidental re-admission of a previously-
/// revoked signer; spec-resolved).
pub fn apply_fof_access_grant_locally(
    storage: &Storage,
    post_id: &[u8; 32],
    new_pub_x: &[u8; 32],
    new_wrap_slot: &crate::types::WrapSlot,
) -> Result<bool> {
    if storage.is_fof_pub_x_revoked(post_id, new_pub_x)? {
        return Ok(false);
    }
    let appended = storage.append_fof_access_grant(post_id, new_pub_x, new_wrap_slot)?;
    Ok(appended)
}

/// Apply a verified revocation to local storage + cascade delete
/// already-stored comments. Idempotent on `(post_id, revoked_pub_x)`.
/// Returns the count of comments deleted.
///
/// Must only be called after `verify_fof_revocation` returns true.
/// The caller (CDN receive path) is responsible for that gate.
pub fn apply_fof_revocation_locally(
    storage: &Storage,
    post_id: &[u8; 32],
    revoked_pub_x: &[u8; 32],
    revoked_at_ms: u64,
    reason_code: u8,
    author_sig: &[u8],
) -> Result<usize> {
    storage.add_fof_revocation(post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig)?;

    // Resolve pub_x -> pub_x_index via the post's pub_post_set, then
    // cascade-delete locally-stored comments matching that index.
    let Some(post) = storage.get_post(post_id)? else { return Ok(0); };
    let Some(gating) = post.fof_gating.as_ref() else { return Ok(0); };
    let mut deleted = 0;
    for (idx, pub_x) in gating.pub_post_set.iter().enumerate() {
        if pub_x == revoked_pub_x {
            deleted += storage.delete_fof_comments_by_pub_x_index(post_id, idx as u32)?;
        }
    }
    Ok(deleted)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::storage::Storage;
    use crate::crypto::{ed25519_seed_to_x25519_private, open_wrap_slot};
    use ed25519_dalek::SigningKey;
    use rand::RngCore;

    fn temp_storage() -> Storage {
        Storage::open(":memory:").unwrap()
    }

    fn make_persona(seed_byte: u8) -> (NodeId, [u8; 32]) {
        let mut seed = [0u8; 32];
        seed[0] = seed_byte;
        let signing_key = SigningKey::from_bytes(&seed);
        (*signing_key.verifying_key().as_bytes(), seed)
    }

    /// Author has a V_me + one received V_x → build_fof_comment_gating
    /// produces a real-slot-count of 2 (own + received), padded to
    /// bucket 8.
    #[test]
    fn build_gating_realcount_and_padding() {
        let s = temp_storage();
        let (alice_id, _alice_seed) = make_persona(1);
        let (bob_id, _bob_seed) = make_persona(2);

        // Alice has her own V_me
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();

        // Alice received a V_x from Bob
        let mut v_x_bob = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_bob);
        s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();

        let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built");
        // Real count = 2 (own + Bob). Bucket = 8 (minimum floor).
        assert_eq!(built.gating.pub_post_set.len(), 8);
        assert_eq!(built.gating.wrap_slots.len(), 8);
        assert_eq!(built.gating.revocation_list.len(), 0);

        // Every slot is exactly 48+48 bytes; prefilter is 2 bytes.
        for slot in &built.gating.wrap_slots {
            assert_eq!(slot.read_ciphertext.len(), 48);
            assert_eq!(slot.sign_ciphertext.len(), 48);
        }

        // Alice can find HER OWN slot by trial-unwrap with her V_me.
        let mut own_hit = None;
        for (idx, slot) in built.gating.wrap_slots.iter().enumerate() {
            if let Some(opened) = open_wrap_slot(
                &v_me_alice, &built.slot_binder_nonce,
                &slot.read_ciphertext, &slot.sign_ciphertext,
            ) {
                assert_eq!(opened.cek, built.cek);
                own_hit = Some((idx, opened));
                break;
            }
        }
        let (own_idx, opened) = own_hit.expect("author's own V_me must unlock one slot");

        // The pub_x in pub_post_set at the same index must match the
        // ed25519 pubkey derived from the unwrapped priv_x seed.
        let signing_key = SigningKey::from_bytes(&opened.priv_x_seed);
        let derived_pub_x = signing_key.verifying_key().to_bytes();
        assert_eq!(built.gating.pub_post_set[own_idx], derived_pub_x);

        // A holder of V_x_bob can also unlock exactly one slot (Bob's
        // chain to Alice's post).
        let mut bob_hit = None;
        for (idx, slot) in built.gating.wrap_slots.iter().enumerate() {
            if let Some(opened) = open_wrap_slot(
                &v_x_bob, &built.slot_binder_nonce,
                &slot.read_ciphertext, &slot.sign_ciphertext,
            ) {
                bob_hit = Some((idx, opened));
                break;
            }
        }
        let (bob_idx, bob_opened) = bob_hit.expect("Bob's V_x must unlock one slot");
        assert_ne!(bob_idx, own_idx, "different V_x's must hit different slots");
        assert_eq!(bob_opened.cek, built.cek, "same CEK across all real slots");
    }

    #[test]
    fn build_gating_returns_none_without_vme() {
        let s = temp_storage();
        let (alice_id, _) = make_persona(5);
        // No V_me inserted.
        let built = build_fof_comment_gating(&s, &alice_id).unwrap();
        assert!(built.is_none(), "no V_me → no gating block");
    }

    #[test]
    fn build_gating_deduplicates_repeated_v_x() {
        let s = temp_storage();
        let (alice_id, _) = make_persona(7);
        let (bob_id, _) = make_persona(8);
        let (carol_id, _) = make_persona(9);

        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();

        // Two different vouchers happened to issue the SAME key bytes
        // (contrived but tests dedup). Real probability is 2^-256;
        // here we force it for the test.
        let same_key = [0xCC; 32];
        s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap();
        s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap();

        let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
        // Unique-key count = 2 (V_me_alice + same_key). Bucket = 8.
        // We can't assert real count directly without exposing internals,
        // but we can confirm exactly two distinct successful unwraps:
        let mut alice_hits = 0;
        let mut same_key_hits = 0;
        for slot in &built.gating.wrap_slots {
            if open_wrap_slot(&v_me_alice, &built.slot_binder_nonce,
                &slot.read_ciphertext, &slot.sign_ciphertext).is_some() {
                alice_hits += 1;
            }
            if open_wrap_slot(&same_key, &built.slot_binder_nonce,
                &slot.read_ciphertext, &slot.sign_ciphertext).is_some() {
                same_key_hits += 1;
            }
        }
        assert_eq!(alice_hits, 1, "exactly one slot for V_me_alice");
        assert_eq!(same_key_hits, 1, "exactly one slot for the duplicated key (dedup'd)");
    }

    // Silences the unused-import warning when the X25519 derivation is
    // only exercised in future slices.
    #[test]
    fn x25519_derivation_helper_compiles() {
        let mut seed = [0u8; 32];
        rand::rng().fill_bytes(&mut seed);
        let _ = ed25519_seed_to_x25519_private(&seed);
    }

    /// End-to-end FoF comment roundtrip:
    ///   1. Alice authors a Mode 2 FoF post (her keyring: own V_me + Bob's V_x).
    ///   2. A "receiver device" (Bob's) holds Bob's V_x. It finds the unlock.
    ///   3. Bob authors a comment on Alice's post.
    ///   4. The comment's group_sig verifies against the post's pub_post_set.
    ///   5. Alice (using her cached cek) decrypts Bob's comment plaintext.
    #[test]
    fn fof_comment_authoring_roundtrip() {
        use crate::types::PostingIdentity;
        use ed25519_dalek::SigningKey;

        // Alice's device: has her V_me and Bob's V_x (received from Bob).
        let alice_storage = temp_storage();
        let (alice_id, alice_seed) = make_persona(1);
        alice_storage.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 1000,
        }).unwrap();
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();

        let (bob_id, _) = make_persona(2);
        let mut v_x_bob = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_bob);
        alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();

        // Alice publishes the gating block.
        let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
        let parent_post_id = [0xCC; 32];

        // Bob's device: has his V_me (which is v_x_bob since he handed it out).
        let bob_storage = temp_storage();
        let (bob_id_, bob_seed) = make_persona(2);
        assert_eq!(bob_id, bob_id_);
        bob_storage.upsert_posting_identity(&PostingIdentity {
            node_id: bob_id, secret_seed: bob_seed,
            display_name: "Bob".into(), created_at: 1500,
        }).unwrap();
        bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap();

        // Wrap Alice's published gating into a Post struct as it would
        // appear on the wire.
        let post = crate::types::Post {
            author: alice_id,
            content: "alice's public post".into(),
            attachments: vec![],
            timestamp_ms: 3000,
            fof_gating: Some(built.gating.clone()),
        supersedes_post_id: None,
        };

        // Bob's device unlocks the post via his V_me (= v_x_bob).
        let unlock = find_unlock_for_post(&bob_storage, &post).unwrap()
            .expect("Bob's persona must unlock the post");
        assert_eq!(unlock.persona_id, bob_id);
        assert_eq!(unlock.cek, built.cek, "Bob recovers Alice's CEK");

        // Bob authors a comment.
        let comment = build_fof_comment(
            &parent_post_id,
            &unlock,
            &built.slot_binder_nonce,
            &bob_id,
            &bob_seed,
            "great post alice",
            None,
            4000,
        ).unwrap();
        assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content");
        assert!(comment.pub_x_index.is_some());
        assert!(comment.group_sig.is_some());
        assert!(comment.encrypted_payload.is_some());

        // CDN-level verification: group_sig validates against pub_post_set.
        assert!(verify_fof_group_sig(&comment, &built.gating),
            "valid FoF comment must pass CDN four-check (group_sig leg)");

        // Tamper detection: flip a byte in encrypted_payload and re-verify.
        let mut tampered = comment.clone();
        let mut payload = tampered.encrypted_payload.clone().unwrap();
        payload[0] ^= 0x01;
        tampered.encrypted_payload = Some(payload);
        assert!(!verify_fof_group_sig(&tampered, &built.gating),
            "tampered encrypted_payload must invalidate group_sig");

        // Tamper detection: claim a different pub_x_index.
        let mut wrong_idx = comment.clone();
        let bad_idx = (comment.pub_x_index.unwrap() + 1) % (built.gating.pub_post_set.len() as u32);
        wrong_idx.pub_x_index = Some(bad_idx);
        assert!(!verify_fof_group_sig(&wrong_idx, &built.gating),
            "wrong pub_x_index must invalidate group_sig");

        // Alice (using her cached CEK) decrypts Bob's comment payload.
        let plaintext = decrypt_fof_comment_payload(&comment, &built.cek, &built.slot_binder_nonce)
            .expect("Alice decrypts FoF comment");
        assert_eq!(plaintext.body, "great post alice");

        let _signing_key = SigningKey::from_bytes(&unlock.priv_x_seed); // exercise the import
    }

    /// Revocation roundtrip: author publishes a Mode 2 FoF post, Bob
    /// comments, author signs a revocation, apply_fof_revocation_locally
    /// records it + cascade-deletes Bob's comment.
    #[test]
    fn fof_revocation_cascades() {
        use crate::types::PostingIdentity;

        let s = temp_storage();
        let (alice_id, alice_seed) = make_persona(33);
        s.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 1000,
        }).unwrap();
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();

        let (bob_id, bob_seed) = make_persona(44);
        let mut v_x_bob = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_bob);
        s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();

        let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
        let post_id = [0xDE; 32];

        // Persist the post so apply_fof_revocation_locally can resolve
        // pub_x → pub_x_index via the post's pub_post_set.
        let post = crate::types::Post {
            author: alice_id, content: "alice".into(), attachments: vec![],
            timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
        supersedes_post_id: None,
        };
        s.store_post_with_intent(
            &post_id, &post,
            &crate::types::PostVisibility::Public,
            &crate::types::VisibilityIntent::Public,
        ).unwrap();

        // Bob unlocks via his V_x and authors a comment. Persist it
        // through the public store_comment path so the cascade-delete
        // has something to clean up.
        let bob_unlock = PostUnlock {
            persona_id: bob_id,
            slot_index: built.gating.pub_post_set.iter().position(|p| {
                // Find a slot Bob's V_x unlocks.
                let opened = crate::crypto::open_wrap_slot(
                    &v_x_bob, &built.slot_binder_nonce,
                    &built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].read_ciphertext,
                    &built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].sign_ciphertext,
                );
                opened.is_some()
            }).expect("Bob's slot exists") as u32,
            cek: built.cek,
            priv_x_seed: {
                // Re-derive by re-unwrapping.
                let mut seed = [0u8; 32];
                for slot in &built.gating.wrap_slots {
                    if let Some(o) = crate::crypto::open_wrap_slot(
                        &v_x_bob, &built.slot_binder_nonce,
                        &slot.read_ciphertext, &slot.sign_ciphertext,
                    ) { seed = o.priv_x_seed; break; }
                }
                seed
            },
        };
        let comment = build_fof_comment(
            &post_id, &bob_unlock, &built.slot_binder_nonce,
            &bob_id, &bob_seed, "hello", None, 4000,
        ).unwrap();
        s.store_comment(&comment).unwrap();
        assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored");

        // Resolve Bob's pub_x bytes from the gating's pub_post_set.
        let bob_pub_x = built.gating.pub_post_set[bob_unlock.slot_index as usize];

        // Author signs + applies revocation.
        let revoked_at = 5000;
        let sig = sign_fof_revocation(&alice_seed, &post_id, &bob_pub_x, revoked_at, 0);
        assert!(verify_fof_revocation(&alice_id, &post_id, &bob_pub_x, revoked_at, 0, &sig));

        let deleted = apply_fof_revocation_locally(
            &s, &post_id, &bob_pub_x, revoked_at, 0, &sig,
        ).unwrap();
        assert_eq!(deleted, 1, "Bob's comment retroactively deleted");
        assert!(s.get_comments(&post_id).unwrap().is_empty(),
            "no live comments remain on the post");

        // Verify revocation is recorded for future CDN-verify lookups.
        assert!(s.is_fof_pub_x_revoked(&post_id, &bob_pub_x).unwrap());

        // Idempotent: re-apply is a no-op (returns 0 because comment already gone).
        let re_deleted = apply_fof_revocation_locally(
            &s, &post_id, &bob_pub_x, revoked_at, 0, &sig,
        ).unwrap();
        assert_eq!(re_deleted, 0);
    }

    /// Access-grant roundtrip: Alice publishes a Mode 2 FoF post, then
    /// later vouches for Carol. Alice signs an access-grant adding
    /// Carol's V_x to the post. apply_fof_access_grant_locally appends
    /// the new slot; Carol's device can now unlock the post.
    #[test]
    fn fof_access_grant_appends_and_unlocks() {
        use crate::types::PostingIdentity;

        let s = temp_storage();
        let (alice_id, alice_seed) = make_persona(60);
        s.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 1000,
        }).unwrap();
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();

        // Initial gating: Alice only.
        let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
        let post_id = [0xBC; 32];
        let post = crate::types::Post {
            author: alice_id, content: "alice".into(), attachments: vec![],
            timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
        supersedes_post_id: None,
        };
        s.store_post_with_intent(
            &post_id, &post,
            &crate::types::PostVisibility::Public,
            &crate::types::VisibilityIntent::Public,
        ).unwrap();

        // Carol's V_x (Carol vouches for herself or is granted access).
        let mut v_x_carol = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_carol);

        // Pre-grant: Carol can NOT unlock the post via her V_x.
        let pre_post = s.get_post(&post_id).unwrap().unwrap();
        let pre_unlock = pre_post.fof_gating.as_ref()
            .map(|g| g.wrap_slots.iter().any(|slot| {
                crate::crypto::open_wrap_slot(
                    &v_x_carol, &g.slot_binder_nonce,
                    &slot.read_ciphertext, &slot.sign_ciphertext,
                ).is_some()
            }))
            .unwrap_or(false);
        assert!(!pre_unlock, "Carol cannot unlock pre-grant");

        // Alice authors an access-grant: seal a new slot under Carol's V_x.
        let mut new_priv_x_seed = [0u8; 32];
        rand::rng().fill_bytes(&mut new_priv_x_seed);
        let new_pub_x = SigningKey::from_bytes(&new_priv_x_seed)
            .verifying_key().to_bytes();
        let sealed = crate::crypto::seal_wrap_slot(
            &v_x_carol, &built.slot_binder_nonce, &built.cek, &new_priv_x_seed,
        ).unwrap();
        let new_wrap_slot = crate::types::WrapSlot {
            prefilter_tag: sealed.prefilter_tag,
            read_ciphertext: sealed.read_ciphertext,
            sign_ciphertext: sealed.sign_ciphertext,
        };

        let granted_at = 5000;
        let sig = sign_fof_access_grant(
            &alice_seed, &post_id, &new_pub_x, &new_wrap_slot, granted_at,
        );
        assert!(verify_fof_access_grant(
            &alice_id, &post_id, &new_pub_x, &new_wrap_slot, granted_at, &sig,
        ));

        let applied = apply_fof_access_grant_locally(
            &s, &post_id, &new_pub_x, &new_wrap_slot,
        ).unwrap();
        assert!(applied, "access-grant appended");

        // Post-grant: stored post's gating now includes Carol's slot.
        let post = s.get_post(&post_id).unwrap().unwrap();
        let g = post.fof_gating.as_ref().unwrap();
        let unlocked = g.wrap_slots.iter().any(|slot| {
            crate::crypto::open_wrap_slot(
                &v_x_carol, &g.slot_binder_nonce,
                &slot.read_ciphertext, &slot.sign_ciphertext,
            ).map(|o| o.cek == built.cek).unwrap_or(false)
        });
        assert!(unlocked, "Carol can now unlock the post and recover Alice's CEK");

        // Idempotent: re-applying the same grant is a no-op.
        let again = apply_fof_access_grant_locally(
            &s, &post_id, &new_pub_x, &new_wrap_slot,
        ).unwrap();
        assert!(!again, "duplicate grant skipped");

        // Revocation blocks re-admission.
        s.add_fof_revocation(&post_id, &new_pub_x, 6000, 0, &[0u8; 64]).unwrap();
        let blocked = apply_fof_access_grant_locally(
            &s, &post_id, &new_pub_x, &new_wrap_slot,
        ).unwrap();
        assert!(!blocked, "revoked pub_x must not be re-granted access");
    }

    /// Key-burn roundtrip: Alice publishes a Mode 2 FoF post sealed
    /// under V_me_old; signs + applies a key-burn that swaps the slot
    /// to a new V_me. After burn, old V_me cannot unlock the burned
    /// slot but new V_me can.
    #[test]
    fn fof_key_burn_replaces_slot() {
        use crate::types::PostingIdentity;
        use ed25519_dalek::SigningKey;

        let s = temp_storage();
        let (alice_id, alice_seed) = make_persona(90);
        s.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 1000,
        }).unwrap();

        // Alice has V_me_old. Build a gating block with just her slot.
        let mut v_me_old = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_old);
        s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap();

        let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
        let post_id = [0xAB; 32];
        let post = crate::types::Post {
            author: alice_id, content: "x".into(), attachments: vec![],
            timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
        supersedes_post_id: None,
        };
        s.store_post_with_intent(
            &post_id, &post,
            &crate::types::PostVisibility::Public,
            &crate::types::VisibilityIntent::Public,
        ).unwrap();

        // Find Alice's slot (the one V_me_old unlocks).
        let alice_slot_idx = (0..built.gating.wrap_slots.len()).find(|&i| {
            crate::crypto::open_wrap_slot(
                &v_me_old, &built.slot_binder_nonce,
                &built.gating.wrap_slots[i].read_ciphertext,
                &built.gating.wrap_slots[i].sign_ciphertext,
            ).is_some()
        }).expect("alice slot exists");

        // Simulate leak: Alice burns the slot under a new V_me.
        let mut v_me_new = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_new);
        let mut new_seed = [0u8; 32];
        rand::rng().fill_bytes(&mut new_seed);
        let new_pub_x = SigningKey::from_bytes(&new_seed).verifying_key().to_bytes();
        let sealed = crate::crypto::seal_wrap_slot(
            &v_me_new, &built.slot_binder_nonce, &built.cek, &new_seed,
        ).unwrap();
        let new_wrap_slot = crate::types::WrapSlot {
            prefilter_tag: sealed.prefilter_tag,
            read_ciphertext: sealed.read_ciphertext,
            sign_ciphertext: sealed.sign_ciphertext,
        };

        let burned_at = 5000;
        let sig = sign_fof_key_burn(
            &alice_seed, &post_id, alice_slot_idx as u32,
            &new_pub_x, &new_wrap_slot, burned_at,
        );
        assert!(verify_fof_key_burn(
            &alice_id, &post_id, alice_slot_idx as u32,
            &new_pub_x, &new_wrap_slot, burned_at, &sig,
        ));

        let applied = apply_fof_key_burn_locally(
            &s, &post_id, alice_slot_idx as u32, &new_pub_x, &new_wrap_slot, burned_at,
        ).unwrap();
        assert!(applied);

        // Post-burn: V_me_old can NO LONGER unlock the burned slot.
        let stored = s.get_post(&post_id).unwrap().unwrap();
        let g = stored.fof_gating.as_ref().unwrap();
        let old_attempt = crate::crypto::open_wrap_slot(
            &v_me_old, &g.slot_binder_nonce,
            &g.wrap_slots[alice_slot_idx].read_ciphertext,
            &g.wrap_slots[alice_slot_idx].sign_ciphertext,
        );
        assert!(old_attempt.is_none(), "V_me_old can no longer unlock the burned slot");

        // V_me_new CAN unlock and recovers the same CEK.
        let new_attempt = crate::crypto::open_wrap_slot(
            &v_me_new, &g.slot_binder_nonce,
            &g.wrap_slots[alice_slot_idx].read_ciphertext,
            &g.wrap_slots[alice_slot_idx].sign_ciphertext,
        ).expect("V_me_new unlocks the new slot");
        assert_eq!(new_attempt.cek, built.cek, "CEK is unchanged across the burn");

        // pub_post_set at that slot is now the new pub_x.
        assert_eq!(g.pub_post_set[alice_slot_idx], new_pub_x);
    }

    // --- Pre-deploy hardening: wire-shape validation + DoS caps ---

    fn dummy_wrap_slot() -> crate::types::WrapSlot {
        crate::types::WrapSlot {
            prefilter_tag: [0u8; 2],
            read_ciphertext: vec![0u8; 48],
            sign_ciphertext: vec![0u8; 48],
        }
    }

    fn dummy_gating(slot_count: usize) -> crate::types::FoFCommentGating {
        crate::types::FoFCommentGating {
            slot_binder_nonce: [0u8; 32],
            pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(),
            wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(),
            revocation_list: vec![],
        }
    }

    fn dummy_post(g: Option<crate::types::FoFCommentGating>) -> crate::types::Post {
        crate::types::Post {
            author: [0u8; 32], content: String::new(), attachments: vec![],
            timestamp_ms: 0, fof_gating: g, supersedes_post_id: None,
        }
    }

    #[test]
    fn validate_rejects_length_mismatch() {
        let mut g = dummy_gating(8);
        g.pub_post_set.pop();
        let p = dummy_post(Some(g));
        let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
        assert!(err.contains("length mismatch"), "got: {}", err);
    }

    #[test]
    fn validate_rejects_oversized_slots() {
        let g = dummy_gating(MAX_FOF_WRAP_SLOTS + 1);
        let p = dummy_post(Some(g));
        let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
        assert!(err.contains("oversized"), "got: {}", err);
    }

    #[test]
    fn validate_rejects_wrong_ciphertext_size() {
        let mut g = dummy_gating(8);
        g.wrap_slots[3].read_ciphertext = vec![0u8; 32]; // wrong size
        let p = dummy_post(Some(g));
        let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
        assert!(err.contains("ciphertext sizes"), "got: {}", err);
    }

    #[test]
    fn validate_accepts_well_formed_gating() {
        let g = dummy_gating(16);
        let p = dummy_post(Some(g));
        validate_fof_gating_on_receive(&p).unwrap();
    }

    #[test]
    fn validate_accepts_post_without_gating() {
        let p = dummy_post(None);
        validate_fof_gating_on_receive(&p).unwrap();
    }

    #[test]
    fn validate_fof_closed_requires_gating() {
        let p = dummy_post(None);
        let err = validate_fof_closed_has_gating(&p, &crate::types::PostVisibility::FoFClosed)
            .unwrap_err().to_string();
        assert!(err.contains("requires a fof_gating"), "got: {}", err);
        // FoFClosed + gating Some → OK
        let p2 = dummy_post(Some(dummy_gating(8)));
        validate_fof_closed_has_gating(&p2, &crate::types::PostVisibility::FoFClosed).unwrap();
        // Public + None → OK
        validate_fof_closed_has_gating(&p, &crate::types::PostVisibility::Public).unwrap();
    }

    #[test]
    fn unreadable_queue_is_capped() {
        let s = temp_storage();
        let (persona, _) = make_persona(200);
        let (author, _) = make_persona(201);
        // Fill to the cap (4096 entries) — use distinct post_ids.
        for i in 0..crate::storage::Storage::max_unreadable_per_persona_for_test() as u32 {
            let mut pid = [0u8; 32];
            pid[..4].copy_from_slice(&i.to_le_bytes());
            s.record_unreadable_post(&persona, &pid, &author, 1000 + i as u64).unwrap();
        }
        // Try to add one more. Should be silently dropped (no INSERT).
        let mut overflow_pid = [0u8; 32];
        overflow_pid[..4].copy_from_slice(&999_999u32.to_le_bytes());
        s.record_unreadable_post(&persona, &overflow_pid, &author, 999_999).unwrap();

        let queued = s.list_all_unreadable_posts(&persona).unwrap();
        assert_eq!(
            queued.len() as i64,
            crate::storage::Storage::max_unreadable_per_persona_for_test(),
            "queue stays at cap; overflow dropped",
        );
        // Overflow post was NOT added.
        assert!(!queued.contains(&overflow_pid));
    }

    /// Key-burn replay rejection: applying an older signed burn after
    /// a newer one must not revert state.
    #[test]
    fn fof_key_burn_replay_rejected() {
        use crate::types::PostingIdentity;
        use ed25519_dalek::SigningKey;

        let s = temp_storage();
        let (alice_id, alice_seed) = make_persona(220);
        s.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 1000,
        }).unwrap();
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();

        let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
        let post_id = [0xEE; 32];
        let post = crate::types::Post {
            author: alice_id, content: String::new(), attachments: vec![],
            timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
            supersedes_post_id: None,
        };
        s.store_post_with_intent(
            &post_id, &post,
            &crate::types::PostVisibility::Public,
            &crate::types::VisibilityIntent::Public,
        ).unwrap();

        // Find Alice's slot.
        let alice_slot_idx = (0..built.gating.wrap_slots.len()).find(|&i| {
            crate::crypto::open_wrap_slot(
                &v_me_alice, &built.slot_binder_nonce,
                &built.gating.wrap_slots[i].read_ciphertext,
                &built.gating.wrap_slots[i].sign_ciphertext,
            ).is_some()
        }).expect("alice slot exists") as u32;

        // First burn (Monday): switch to V_me_new1.
        let mut v_me_new1 = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new1);
        let mut seed1 = [0u8; 32]; rand::rng().fill_bytes(&mut seed1);
        let pub_x1 = SigningKey::from_bytes(&seed1).verifying_key().to_bytes();
        let sealed1 = crate::crypto::seal_wrap_slot(
            &v_me_new1, &built.slot_binder_nonce, &built.cek, &seed1,
        ).unwrap();
        let wrap1 = crate::types::WrapSlot {
            prefilter_tag: sealed1.prefilter_tag,
            read_ciphertext: sealed1.read_ciphertext,
            sign_ciphertext: sealed1.sign_ciphertext,
        };
        let monday = 100_000;
        apply_fof_key_burn_locally(&s, &post_id, alice_slot_idx, &pub_x1, &wrap1, monday).unwrap();

        // Second burn (Friday, later timestamp): switch to V_me_new2.
        let mut v_me_new2 = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new2);
        let mut seed2 = [0u8; 32]; rand::rng().fill_bytes(&mut seed2);
        let pub_x2 = SigningKey::from_bytes(&seed2).verifying_key().to_bytes();
        let sealed2 = crate::crypto::seal_wrap_slot(
            &v_me_new2, &built.slot_binder_nonce, &built.cek, &seed2,
        ).unwrap();
        let wrap2 = crate::types::WrapSlot {
            prefilter_tag: sealed2.prefilter_tag,
            read_ciphertext: sealed2.read_ciphertext,
            sign_ciphertext: sealed2.sign_ciphertext,
        };
        let friday = 200_000;
        apply_fof_key_burn_locally(&s, &post_id, alice_slot_idx, &pub_x2, &wrap2, friday).unwrap();

        // Replay of Monday's burn (older timestamp) — must be rejected.
        let applied = apply_fof_key_burn_locally(
            &s, &post_id, alice_slot_idx, &pub_x1, &wrap1, monday,
        ).unwrap();
        assert!(!applied, "older burn must be rejected as replay");

        // Stored state must still reflect Friday's burn.
        let stored = s.get_post(&post_id).unwrap().unwrap();
        let g = stored.fof_gating.as_ref().unwrap();
        assert_eq!(g.pub_post_set[alice_slot_idx as usize], pub_x2,
            "Friday's pub_x preserved despite replayed Monday burn");
    }

    #[test]
    fn body_bucket_rule_boundaries() {
        // Sub-1KB.
        assert_eq!(next_body_size_bucket(0), 1024);
        assert_eq!(next_body_size_bucket(1024), 1024);
        // Power-of-2 progression sub-256KB.
        assert_eq!(next_body_size_bucket(1025), 2048);
        assert_eq!(next_body_size_bucket(2048), 2048);
        assert_eq!(next_body_size_bucket(100_000), 131_072); // 128KB
        assert_eq!(next_body_size_bucket(200_000), 262_144); // 256KB
        // Linear +256KB above.
        assert_eq!(next_body_size_bucket(262_145), 524_288); // 512KB
        assert_eq!(next_body_size_bucket(500_000), 524_288);
        assert_eq!(next_body_size_bucket(524_289), 786_432); // 768KB
    }

    #[test]
    fn fof_body_roundtrip() {
        let cek = [0x55; 32];
        let slot_binder_nonce = [0xCC; 32];
        let body = "Hello, this is a Mode 1 FoFClosed body that nobody outside the FoF set should be able to read on the wire.";

        let encrypted = encrypt_fof_body(body, &cek, &slot_binder_nonce).unwrap();
        // Encryption pads to bucket: small body → 1KB ciphertext-ish.
        assert!(encrypted.len() >= 1024, "body padded to >= 1KB bucket");

        let decrypted = decrypt_fof_body(&encrypted, &cek, &slot_binder_nonce).unwrap();
        assert_eq!(decrypted, body);

        // Wrong CEK fails AEAD.
        let wrong_cek = [0x11; 32];
        assert!(decrypt_fof_body(&encrypted, &wrong_cek, &slot_binder_nonce).is_err());

        // Wrong AAD (slot_binder_nonce) fails too.
        let wrong_nonce = [0xFF; 32];
        assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err());
    }

    /// FoF Layer 5: first find_unlock_for_post call populates the
    /// cache; subsequent calls hit the fast path (cache lookup, single
    /// AEAD attempt). Tested by verifying the cache table after first
    /// call + cleared-unreadable invariant.
    #[test]
    fn fof_unlock_cache_populates_and_hits() {
        use crate::types::PostingIdentity;

        let s = temp_storage();
        let (alice_id, alice_seed) = make_persona(100);
        let (bob_id, bob_seed) = make_persona(101);
        s.upsert_posting_identity(&PostingIdentity {
            node_id: bob_id, secret_seed: bob_seed,
            display_name: "Bob".into(), created_at: 1000,
        }).unwrap();
        let mut v_x_bob = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_bob);
        s.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1000).unwrap();

        // Build a post from Alice that includes Bob's V_x.
        let alice_storage = temp_storage();
        alice_storage.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 500,
        }).unwrap();
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap();
        alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap();
        let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
        let post = crate::types::Post {
            author: alice_id, content: String::new(), attachments: vec![],
            timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
            supersedes_post_id: None,
        };

        // Bob's first scan — full scan path, populates cache.
        let unlock1 = find_unlock_for_post(&s, &post).unwrap().expect("Bob unlocks");
        assert_eq!(unlock1.cek, built.cek);
        let cached = s.lookup_unlock_cache(&bob_id, &alice_id).unwrap().expect("cache populated");
        // Bob's V_x is owned by Bob himself (insert_own_vouch_key).
        assert_eq!(cached.0, bob_id);
        assert_eq!(cached.1, 1);

        // Second call should hit the cache. Sanity: still unlocks.
        let unlock2 = find_unlock_for_post(&s, &post).unwrap().expect("cache hit");
        assert_eq!(unlock2.cek, built.cek);
        assert_eq!(unlock2.slot_index, unlock1.slot_index);
    }

    /// FoF Layer 5: non-member persona records the post as unreadable;
    /// later arrival of a matching V_x + sweep brings it back into
    /// readability and clears the queue.
    #[test]
    fn fof_unreadable_sweep_after_v_x_arrival() {
        use crate::types::PostingIdentity;

        // Build a post from Alice that requires Carol's V_x to unlock.
        let alice_storage = temp_storage();
        let (alice_id, alice_seed) = make_persona(110);
        alice_storage.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 100,
        }).unwrap();
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 100).unwrap();

        let (carol_id, _) = make_persona(111);
        let mut v_x_carol = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_carol);
        alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap();

        let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");

        // Bob's storage: holds his own V_me only (no Carol-V_x). The post
        // shouldn't unlock for him yet.
        let s = temp_storage();
        let (bob_id, bob_seed) = make_persona(112);
        s.upsert_posting_identity(&PostingIdentity {
            node_id: bob_id, secret_seed: bob_seed,
            display_name: "Bob".into(), created_at: 300,
        }).unwrap();
        let mut v_me_bob = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_bob);
        s.insert_own_vouch_key(&bob_id, 1, &v_me_bob, 300).unwrap();

        let post = crate::types::Post {
            author: alice_id, content: String::new(), attachments: vec![],
            timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
            supersedes_post_id: None,
        };
        // Persist the post so the sweep can re-fetch it.
        s.store_post_with_intent(
            &crate::content::compute_post_id(&post), &post,
            &crate::types::PostVisibility::Public,
            &crate::types::VisibilityIntent::Public,
        ).unwrap();

        // First attempt: no V_x matches → unreadable queue grows.
        let pre = find_unlock_for_post(&s, &post).unwrap();
        assert!(pre.is_none(), "Bob can't unlock pre-V_x");
        let queued = s.list_unreadable_posts_for_author(&bob_id, &alice_id).unwrap();
        assert_eq!(queued.len(), 1);

        // Carol vouches for Bob via... wait, Carol's V_x is sealed in
        // Alice's post for Carol. Bob is unrelated. To make this test
        // realistic, let's say Carol DOES vouch for Bob — Bob now
        // holds V_x_carol in his keyring. After the sweep, Bob can
        // unlock Alice's post via Carol's V_x (which IS sealed in the
        // gating because Alice held Carol's V_x).
        s.insert_received_vouch_key(&bob_id, &carol_id, 1, &v_x_carol, 400, None).unwrap();
        let swept = sweep_unreadable_on_new_v_x(&s, &bob_id, &carol_id).unwrap();
        assert_eq!(swept, 1, "sweep unlocks Alice's post via Carol's V_x");

        // Post-sweep: unreadable queue cleared; unlock cache populated.
        let after = s.list_unreadable_posts_for_author(&bob_id, &alice_id).unwrap();
        assert!(after.is_empty());
        let cached = s.lookup_unlock_cache(&bob_id, &alice_id).unwrap().expect("cache populated");
        assert_eq!(cached.0, carol_id, "winning V_x owner is Carol");
        assert_eq!(cached.1, 1);
    }

    /// End-to-end FoFClosed roundtrip at the helper level: Alice
    /// encrypts a body; Bob (with Alice's V_me as a received V_x)
    /// trial-unlocks the gating + decrypts the body. Carol (no
    /// matching V_x) cannot unlock and the body stays opaque.
    #[test]
    fn fof_closed_body_end_to_end() {
        use crate::types::PostingIdentity;

        let s = temp_storage();

        // Alice has V_me; she'll author a FoFClosed post.
        let (alice_id, alice_seed) = make_persona(70);
        s.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 1000,
        }).unwrap();
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();

        // Alice received Bob's V_x — so the gating includes Bob's slot.
        let (bob_id, _bob_seed) = make_persona(71);
        let mut v_x_bob = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_bob);
        s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();

        let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
        let body_plaintext = "secret to the FoF set only";
        let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap();

        // Bob's device (with his V_me == v_x_bob) sees the gating block
        // and trial-unlocks via his V_me.
        let bob_storage = temp_storage();
        bob_storage.upsert_posting_identity(&PostingIdentity {
            node_id: bob_id, secret_seed: _bob_seed,
            display_name: "Bob".into(), created_at: 1500,
        }).unwrap();
        bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap();

        let alice_post = crate::types::Post {
            author: alice_id, content: String::new(), attachments: vec![],
            timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
        supersedes_post_id: None,
        };
        let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap()
            .expect("Bob can unlock");
        let bob_decrypted = decrypt_fof_body(&body_ct, &bob_unlock.cek, &built.slot_binder_nonce).unwrap();
        assert_eq!(bob_decrypted, body_plaintext);

        // Carol has no matching V_x — cannot unlock.
        let carol_storage = temp_storage();
        let (carol_id, carol_seed) = make_persona(72);
        carol_storage.upsert_posting_identity(&PostingIdentity {
            node_id: carol_id, secret_seed: carol_seed,
            display_name: "Carol".into(), created_at: 1500,
        }).unwrap();
        let mut v_me_carol = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_carol);
        carol_storage.insert_own_vouch_key(&carol_id, 1, &v_me_carol, 1500).unwrap();

        let carol_unlock = find_unlock_for_post(&carol_storage, &alice_post).unwrap();
        assert!(carol_unlock.is_none(),
            "Carol has no matching V_x and cannot unlock the FoFClosed gating");
    }

    #[test]
    fn fof_body_padding_hides_real_length() {
        let cek = [0x55; 32];
        let nonce = [0xCC; 32];
        // A 5-byte body and a 500-byte body should both produce the
        // same on-wire size (1KB bucket).
        let small = encrypt_fof_body("short", &cek, &nonce).unwrap();
        let medium = encrypt_fof_body(&"x".repeat(500), &cek, &nonce).unwrap();
        assert_eq!(small.len(), medium.len(),
            "different bodies in the same bucket produce same-sized ciphertexts");
    }

    /// Provenance roundtrip: build_fof_comment_gating populates
    /// real_slot_provenance; entries match the actual real slots in
    /// the gating block.
    #[test]
    fn fof_gating_real_slot_provenance() {
        use crate::types::PostingIdentity;
        use ed25519_dalek::SigningKey;

        let s = temp_storage();
        let (alice_id, alice_seed) = make_persona(80);
        s.upsert_posting_identity(&PostingIdentity {
            node_id: alice_id, secret_seed: alice_seed,
            display_name: "Alice".into(), created_at: 1000,
        }).unwrap();
        let mut v_me_alice = [0u8; 32];
        rand::rng().fill_bytes(&mut v_me_alice);
        s.insert_own_vouch_key(&alice_id, 7, &v_me_alice, 1000).unwrap();

        // Two received V_x's at different epochs.
        let (bob_id, _) = make_persona(81);
        let (carol_id, _) = make_persona(82);
        let mut v_x_bob = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_bob);
        let mut v_x_carol = [0u8; 32];
        rand::rng().fill_bytes(&mut v_x_carol);
        s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap();
        s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap();

        let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
        // 3 unique V_x's → 3 real slots, padded to bucket 8.
        assert_eq!(built.real_slot_provenance.len(), 3);
        assert_eq!(built.gating.pub_post_set.len(), 8);

        // Provenance entries must reference real positions whose pub_x
        // matches gating.pub_post_set[slot_index].
        for prov in &built.real_slot_provenance {
            assert_eq!(
                built.gating.pub_post_set[prov.slot_index as usize],
                prov.pub_x,
                "provenance pub_x matches gating at indexed slot"
            );
        }

        // Provenance covers exactly Alice's own V_me (epoch 7) + Bob (3) + Carol (5).
        let owners: Vec<NodeId> = built.real_slot_provenance.iter().map(|p| p.v_x_owner).collect();
        assert!(owners.contains(&alice_id));
        assert!(owners.contains(&bob_id));
        assert!(owners.contains(&carol_id));
        let epochs: Vec<u32> = built.real_slot_provenance.iter().map(|p| p.v_x_epoch).collect();
        assert!(epochs.contains(&7));
        assert!(epochs.contains(&3));
        assert!(epochs.contains(&5));

        // The pub_x derived from each real slot's signing seed must
        // match the published pub_post_set entry.
        for prov in &built.real_slot_provenance {
            for v_x in [v_me_alice, v_x_bob, v_x_carol].iter() {
                if let Some(opened) = crate::crypto::open_wrap_slot(
                    v_x, &built.slot_binder_nonce,
                    &built.gating.wrap_slots[prov.slot_index as usize].read_ciphertext,
                    &built.gating.wrap_slots[prov.slot_index as usize].sign_ciphertext,
                ) {
                    let derived = SigningKey::from_bytes(&opened.priv_x_seed)
                        .verifying_key().to_bytes();
                    assert_eq!(derived, prov.pub_x);
                    break;
                }
            }
        }
    }

    #[test]
    fn fof_revocation_wrong_author_rejected() {
        let post_id = [0x01; 32];
        let revoked_pub_x = [0x02; 32];
        let (alice_id, alice_seed) = make_persona(50);
        let (mallory_id, mallory_seed) = make_persona(51);

        let sig = sign_fof_revocation(&mallory_seed, &post_id, &revoked_pub_x, 1000, 0);
        // Mallory signed but claims Alice authored → reject.
        assert!(!verify_fof_revocation(&alice_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
        // Self-signed → accept.
        assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
        let _ = alice_seed;
    }
}
