//! Group-key distribution as an encrypted post.
//!
//! v0.6.2 replaces the v0.6.1 `GroupKeyDistribute` wire push (admin →
//! member, uni-stream) with a standard public post that carries the group
//! seed inside `PostVisibility::Encrypted`. Each member is a recipient; the
//! post's CEK is wrapped per member using the admin's posting key. Members
//! receive the post via normal CDN / pull paths, decrypt with their posting
//! secret, and recover the seed + metadata.
//!
//! Removing the direct push eliminates the wire-level signal that a given
//! network endpoint is coordinating group membership with another specific
//! endpoint.
//!
//! Note: Members are identified by their **posting** NodeIds (the
//! author/recipient namespace since the v0.6.1 identity split), not network
//! NodeIds. The admin wraps the CEK using their default_posting_secret; the
//! receiver unwraps using one of their posting identity secrets.

use crate::content::compute_post_id;
use crate::crypto;
use crate::storage::Storage;
use crate::types::{
    GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId,
    PostVisibility, PostingIdentity, VisibilityIntent,
};

/// Build an encrypted key-distribution post. Authored by the admin's
/// posting identity; recipients are the member posting NodeIds. Returns
/// `(PostId, Post, PostVisibility)` — caller stores with intent=
/// `GroupKeyDistribute` and propagates via the normal neighbor-manifest CDN
/// path.
pub fn build_distribution_post(
    admin: &NodeId,
    admin_secret: &[u8; 32],
    record: &GroupKeyRecord,
    group_seed: &[u8; 32],
    members: &[NodeId],
) -> anyhow::Result<(PostId, Post, PostVisibility)> {
    let content = GroupKeyDistributionContent {
        group_id: record.group_id,
        circle_name: record.circle_name.clone(),
        epoch: record.epoch,
        group_public_key: record.group_public_key,
        admin: *admin,
        canonical_root_post_id: record.canonical_root_post_id,
        group_seed: *group_seed,
    };
    let plaintext = serde_json::to_string(&content)?;

    // Wrap the CEK to each member (their posting pubkey).
    let (ciphertext_b64, wrapped_keys) =
        crypto::encrypt_post(&plaintext, admin_secret, admin, members)?;

    let timestamp_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);

    let post = Post {
        author: *admin,
        content: ciphertext_b64,
        attachments: vec![],
        timestamp_ms,
    fof_gating: None,
    supersedes_post_id: None,
    };
    let post_id = compute_post_id(&post);
    let visibility = PostVisibility::Encrypted { recipients: wrapped_keys };
    Ok((post_id, post, visibility))
}

/// Attempt to decrypt + apply a stored GroupKeyDistribute post using each
/// posting identity's secret in turn. Returns `Ok(true)` on successful
/// apply, `Ok(false)` if none of our personas were recipients (or content
/// was malformed, or the seed had already been stored), `Err` on hard
/// errors during storage.
pub fn try_apply_distribution_post(
    s: &Storage,
    post: &Post,
    visibility: &PostVisibility,
    our_personas: &[PostingIdentity],
) -> anyhow::Result<bool> {
    let wrapped_keys = match visibility {
        PostVisibility::Encrypted { recipients } => recipients,
        _ => return Ok(false), // Only Encrypted posts can carry seeds.
    };

    for persona in our_personas {
        match crypto::decrypt_post(
            &post.content,
            &persona.secret_seed,
            &persona.node_id,
            &post.author,
            wrapped_keys,
        ) {
            Ok(Some(plaintext)) => {
                let content: GroupKeyDistributionContent = match serde_json::from_str(&plaintext) {
                    Ok(c) => c,
                    Err(_) => continue, // Bad payload — try next persona.
                };
                // Critical: the `admin` claimed inside the decrypted
                // payload must match the post author. Without this, any
                // peer who knows a member's posting id and the group's
                // group_id could craft an encrypted post claiming to be
                // from the admin and overwrite the member's stored group
                // key (create_group_key uses INSERT OR REPLACE).
                if content.admin != post.author {
                    tracing::warn!(
                        post_author = hex::encode(post.author),
                        claimed_admin = hex::encode(content.admin),
                        group_id = hex::encode(content.group_id),
                        "rejecting group-key-distribution post: claimed admin != post author"
                    );
                    continue;
                }
                apply_content(s, &content)?;
                return Ok(true);
            }
            Ok(None) | Err(_) => continue,
        }
    }
    Ok(false)
}

fn apply_content(s: &Storage, content: &GroupKeyDistributionContent) -> anyhow::Result<()> {
    let record = GroupKeyRecord {
        group_id: content.group_id,
        circle_name: content.circle_name.clone(),
        epoch: content.epoch,
        group_public_key: content.group_public_key,
        admin: content.admin,
        created_at: std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0),
        canonical_root_post_id: content.canonical_root_post_id,
    };
    s.create_group_key(&record, Some(&content.group_seed))?;
    s.store_group_seed(&content.group_id, content.epoch, &content.group_seed)?;
    Ok(())
}

/// Scan stored posts with `VisibilityIntent::GroupKeyDistribute` and apply
/// any that one of our posting identities can decrypt. Intended to run
/// after a pull-sync so newly-received distribution posts take effect
/// immediately.
pub fn process_pending(
    s: &Storage,
    our_personas: &[PostingIdentity],
) -> anyhow::Result<usize> {
    // Cheap scan: iterate all posts, filter by intent. The table is small
    // in practice (few groups × few epochs).
    let all = s.list_posts_with_visibility()?;
    let mut applied = 0;
    for (id, post, visibility) in all {
        let intent = s.get_post_intent(&id)?;
        if !matches!(intent, Some(VisibilityIntent::GroupKeyDistribute)) {
            continue;
        }
        if try_apply_distribution_post(s, &post, &visibility, our_personas)? {
            applied += 1;
        }
    }
    Ok(applied)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::storage::Storage;
    use ed25519_dalek::SigningKey;

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

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

    fn mk_persona(seed: [u8; 32], node_id: NodeId) -> PostingIdentity {
        PostingIdentity {
            node_id,
            secret_seed: seed,
            display_name: String::new(),
            created_at: 0,
        }
    }

    #[test]
    fn forged_admin_is_rejected() {
        // Scenario: an attacker knows the victim's posting pubkey and the
        // target group_id. They craft an encrypted distribution post
        // addressed to the victim, claiming themselves as the group admin.
        // Without the author-vs-admin check the victim would overwrite
        // their legitimate group key record.
        let s = temp_storage();
        let (real_admin_sec, real_admin_id) = make_keypair(1);
        let (attacker_sec, attacker_id) = make_keypair(9);
        let (victim_sec, victim_id) = make_keypair(2);

        // Seed the victim with a legitimate group record so we can
        // verify it isn't overwritten by the forgery.
        let group_id = [77u8; 32];
        let real_pubkey = [1u8; 32];
        let real_seed = [42u8; 32];
        let real_record = GroupKeyRecord {
            group_id,
            circle_name: "real".to_string(),
            epoch: 1,
            group_public_key: real_pubkey,
            admin: real_admin_id,
            created_at: 100,
            canonical_root_post_id: None,
        };
        let (_, real_post, real_vis) = build_distribution_post(
            &real_admin_id, &real_admin_sec, &real_record, &real_seed, &[victim_id],
        ).unwrap();
        let victim_personas = vec![mk_persona(victim_sec, victim_id)];
        assert!(try_apply_distribution_post(&s, &real_post, &real_vis, &victim_personas).unwrap());
        assert_eq!(s.get_group_key(&group_id).unwrap().unwrap().admin, real_admin_id);

        // Attacker authors a forgery: post.author is attacker, but the
        // inner `admin` field claims to be the real admin.
        let forged_content = GroupKeyDistributionContent {
            group_id,
            circle_name: "real".to_string(),
            epoch: 2,
            group_public_key: [255u8; 32],
            admin: real_admin_id, // lies inside the encrypted payload
            canonical_root_post_id: None,
            group_seed: [0xFFu8; 32],
        };
        let plaintext = serde_json::to_string(&forged_content).unwrap();
        let (ciphertext, wrapped) = crate::crypto::encrypt_post(
            &plaintext, &attacker_sec, &attacker_id, &[victim_id],
        ).unwrap();
        let forged_post = Post {
            author: attacker_id, // real author — attacker, not admin
            content: ciphertext,
            attachments: vec![],
            timestamp_ms: 200,
        fof_gating: None,
        supersedes_post_id: None,
        };
        let forged_vis = PostVisibility::Encrypted { recipients: wrapped };

        let applied = try_apply_distribution_post(&s, &forged_post, &forged_vis, &victim_personas).unwrap();
        assert!(!applied, "forged distribution post must not be applied");

        // Legitimate group key must be untouched.
        let stored = s.get_group_key(&group_id).unwrap().unwrap();
        assert_eq!(stored.admin, real_admin_id);
        assert_eq!(stored.group_public_key, real_pubkey);
    }

    #[test]
    fn member_decrypts_and_applies() {
        let s = temp_storage();
        let (admin_sec, admin_id) = make_keypair(1);
        let (member_sec, member_id) = make_keypair(2);
        let (nonmember_sec, nonmember_id) = make_keypair(3);

        let group_id = [42u8; 32];
        let group_pubkey = [7u8; 32];
        let group_seed = [9u8; 32];
        let record = GroupKeyRecord {
            group_id,
            circle_name: "fam".to_string(),
            epoch: 1,
            group_public_key: group_pubkey,
            admin: admin_id,
            created_at: 100,
            canonical_root_post_id: None,
        };

        let (_pid, post, visibility) = build_distribution_post(
            &admin_id, &admin_sec, &record, &group_seed, &[member_id],
        ).unwrap();

        // Member applies successfully.
        let member_personas = vec![mk_persona(member_sec, member_id)];
        let applied = try_apply_distribution_post(&s, &post, &visibility, &member_personas).unwrap();
        assert!(applied);
        let stored = s.get_group_key(&group_id).unwrap().unwrap();
        assert_eq!(stored.circle_name, "fam");
        let seed = s.get_group_seed(&group_id, 1).unwrap().unwrap();
        assert_eq!(seed, group_seed);

        // Non-member can't.
        let s2 = temp_storage();
        let nonmember_personas = vec![mk_persona(nonmember_sec, nonmember_id)];
        let applied2 = try_apply_distribution_post(&s2, &post, &visibility, &nonmember_personas).unwrap();
        assert!(!applied2);
        assert!(s2.get_group_key(&group_id).unwrap().is_none());
    }
}
