//! 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: own current V_me + all unique
    // received V_x's (deduped at the byte level per Layer 3).
    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 highest epoch per (owner, key).
    let mut unique_keys: Vec<[u8; 32]> = Vec::with_capacity(1 + received.len());
    unique_keys.push(own_v_me);
    for (_owner, _epoch, key) in &received {
        if !unique_keys.iter().any(|existing| existing == key) {
            unique_keys.push(*key);
        }
    }

    // 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. Build pub_post_set in lockstep with wrap_slots so the
    // .len() invariant holds and indices map cleanly.
    let mut entries: Vec<([u8; 32], WrapSlot)> = Vec::with_capacity(unique_keys.len());
    for v_x in &unique_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((pub_x, slot));
    }

    // Pad to bucket with dummies. Dummy pub_x = 32B random bytes (no
    // priv_x exists; group_sig verification against it will always
    // fail — benign). Dummy slot = 98B random; AEAD-fails on every V_x.
    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((
            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 (pub_post_set, wrap_slots): (Vec<_>, Vec<_>) = entries.into_iter().unzip();

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

    Ok(Some(FoFCommentGatingBuilt {
        gating,
        // Returned to the caller because the author needs them locally:
        //   - cek: to decrypt their own comments later
        //   - own pub_x: to find their own slot in pub_post_set for
        //     authoring author-side comments + future access-grants
        cek,
        slot_binder_nonce,
    }))
}

/// Output of [`build_fof_comment_gating`]. The gating block goes into
/// `Post.fof_gating`; the side outputs are author-local state the
/// caller should cache (e.g., in `own_post_slot_provenance` introduced
/// in the cascade-revocation slice later).
#[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],
}

// --- 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.
///
/// Iteration order: personas as listed by storage; within each persona,
/// own current `V_me` first, then received V_x's. Slots are scanned in
/// order; the 2B prefilter lets us skip non-matching slots in O(1) per.
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 personas = storage.list_posting_identities()?;
    for persona in &personas {
        // Build this persona's V_x ring: own current + every received.
        let mut keys: Vec<[u8; 32]> = Vec::new();
        if let Some((_, own_key)) = storage.current_own_vouch_key(&persona.node_id)? {
            keys.push(own_key);
        }
        for (_owner, _epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? {
            keys.push(key);
        }
        for v_x in &keys {
            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 Ok(Some(PostUnlock {
                        persona_id: persona.node_id,
                        slot_index: idx as u32,
                        cek: opened.cek,
                        priv_x_seed: opened.priv_x_seed,
                    }));
                }
            }
        }
    }
    Ok(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))
}

#[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()),
        };

        // 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
    }
}
