//! Network-wide announcements, authored only by the bootstrap anchor's
//! posting identity (see `crate::DEFAULT_ANCHOR_POSTING_ID`).
//!
//! Shape: a standard public post with `VisibilityIntent::Announcement`
//! whose content is a JSON `AnnouncementContent` carrying a signed
//! payload. Propagation uses the normal CDN path; there is no direct
//! push. Because the signature check is against a single hardcoded key,
//! any peer can try to publish an announcement but only the real anchor's
//! posts verify — forgeries are rejected before storage in
//! `control::receive_post`, and thus never re-propagate via neighbor
//! manifest diffs.
//!
//! Use cases: release notices (triggers welcome-screen upgrade banner),
//! protocol notices, security-relevant admin broadcasts. Intended to be
//! used sparingly.

use crate::crypto;
use crate::storage::Storage;
use crate::types::{
    AnnouncementContent, NodeId, Post, PostId, PostVisibility, ReleaseAnnouncement,
    VisibilityIntent,
};

/// Verify that a candidate post is a valid announcement: its author must
/// be `DEFAULT_ANCHOR_POSTING_ID` and its signature must verify. Returns
/// the decoded `AnnouncementContent` on success.
pub fn verify_announcement_post(post: &Post) -> anyhow::Result<AnnouncementContent> {
    if post.author != crate::DEFAULT_ANCHOR_POSTING_ID {
        anyhow::bail!("announcement author is not the bootstrap anchor");
    }
    let content: AnnouncementContent = serde_json::from_str(&post.content).map_err(|e| {
        anyhow::anyhow!("announcement content is not a valid AnnouncementContent: {}", e)
    })?;
    if !crypto::verify_announcement(
        &post.author,
        &content.category,
        &content.title,
        &content.body,
        content.timestamp_ms,
        &content.release,
        &content.signature,
    ) {
        anyhow::bail!("invalid announcement signature");
    }
    Ok(content)
}

/// If the post is an announcement, verify + apply by upserting into the
/// `settings`-backed latest-announcement slot(s). Only applied if newer
/// than the stored row for the same category/channel (last-writer-wins
/// by timestamp).
pub fn apply_announcement_if_applicable(
    s: &Storage,
    post: &Post,
    intent: Option<&VisibilityIntent>,
) -> anyhow::Result<()> {
    if !matches!(intent, Some(VisibilityIntent::Announcement)) {
        return Ok(());
    }
    let content = verify_announcement_post(post)?;

    // Serialize the full content + a post_id pointer so the UI can render
    // the banner without re-fetching the post. Storage is settings-kv
    // keyed by "announcement:<category>:<channel>".
    let post_id = crate::content::compute_post_id(post);
    let key = announcement_key(&content.category, content.release.as_ref().map(|r| r.channel.as_str()));

    if let Some(existing_ts) = s.get_setting(&format!("{}:ts", key))? {
        if let Ok(prev) = existing_ts.parse::<u64>() {
            if prev >= content.timestamp_ms {
                return Ok(());
            }
        }
    }

    let envelope = StoredAnnouncement {
        post_id,
        content: content.clone(),
    };
    let json = serde_json::to_string(&envelope)?;
    s.set_setting(&key, &json)?;
    s.set_setting(&format!("{}:ts", key), &content.timestamp_ms.to_string())?;
    Ok(())
}

fn announcement_key(category: &str, channel: Option<&str>) -> String {
    match channel {
        Some(ch) => format!("announcement:{}:{}", category, ch),
        None => format!("announcement:{}", category),
    }
}

/// Envelope stored in settings so the UI can fetch the latest announcement
/// without a full post scan. Includes the post_id so the UI can jump to
/// the full post if needed.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StoredAnnouncement {
    pub post_id: PostId,
    pub content: AnnouncementContent,
}

/// Read the latest release announcement for a channel, if any.
pub fn latest_release(s: &Storage, channel: &str) -> anyhow::Result<Option<StoredAnnouncement>> {
    let key = announcement_key("release", Some(channel));
    match s.get_setting(&key)? {
        Some(json) => Ok(serde_json::from_str(&json).ok()),
        None => Ok(None),
    }
}

/// Build a signed announcement post. Must be called with the anchor's
/// posting keypair (its seed matching `DEFAULT_ANCHOR_POSTING_ID`).
pub fn build_announcement_post(
    author: &NodeId,
    author_secret: &[u8; 32],
    category: &str,
    title: &str,
    body: &str,
    release: Option<ReleaseAnnouncement>,
) -> Post {
    let timestamp_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);
    let signature = crypto::sign_announcement(
        author_secret,
        category,
        title,
        body,
        timestamp_ms,
        &release,
    );
    let content = AnnouncementContent {
        category: category.to_string(),
        title: title.to_string(),
        body: body.to_string(),
        timestamp_ms,
        release,
        signature,
    };
    Post {
        author: *author,
        content: serde_json::to_string(&content).unwrap_or_default(),
        attachments: vec![],
        timestamp_ms,
    fof_gating: None,
    supersedes_post_id: None,
    }
}

/// Post visibility is always Public — the signature binds the content
/// and only the anchor key can produce a verifying post.
pub fn announcement_visibility() -> PostVisibility {
    PostVisibility::Public
}

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

    /// Override DEFAULT_ANCHOR_POSTING_ID for tests by using whatever key
    /// we generate. We can't change the const, so instead verify_announcement_post
    /// is called with a post whose author matches the real const — we test
    /// the signature + serialization logic in isolation via a custom flow.
    #[test]
    fn announcement_signature_roundtrip() {
        let (sec, pubk) = make_keypair(0xaa);
        let release = Some(ReleaseAnnouncement {
            channel: "stable".to_string(),
            version: "0.6.3".to_string(),
            download_url: "https://itsgoin.com/download.html".to_string(),
        });
        let ts = 1234u64;
        let sig = crypto::sign_announcement(&sec, "release", "v0.6.3 available", "notes", ts, &release);
        assert!(crypto::verify_announcement(&pubk, "release", "v0.6.3 available", "notes", ts, &release, &sig));

        // Flipping any field should break verification.
        assert!(!crypto::verify_announcement(&pubk, "release", "v0.6.4 available", "notes", ts, &release, &sig));
        let other_release = Some(ReleaseAnnouncement {
            channel: "beta".to_string(),
            ..release.as_ref().unwrap().clone()
        });
        assert!(!crypto::verify_announcement(&pubk, "release", "v0.6.3 available", "notes", ts, &other_release, &sig));
    }

    #[test]
    fn non_anchor_author_is_rejected() {
        // Craft a post whose author is NOT the hardcoded anchor key.
        let (sec, pubk) = make_keypair(0xbb);
        let release = Some(ReleaseAnnouncement {
            channel: "stable".to_string(),
            version: "0.6.3".to_string(),
            download_url: "x".to_string(),
        });
        let post = build_announcement_post(&pubk, &sec, "release", "Hi", "", release);
        let res = verify_announcement_post(&post);
        assert!(res.is_err(), "post authored by non-anchor key must be rejected");
    }

    #[test]
    fn apply_noops_on_non_announcement_intent() {
        let s = temp_storage();
        let (sec, pubk) = make_keypair(0xcc);
        let post = build_announcement_post(&pubk, &sec, "release", "t", "b", None);
        // Not an announcement intent — apply should no-op and return Ok.
        apply_announcement_if_applicable(&s, &post, Some(&VisibilityIntent::Public)).unwrap();
        assert!(s.get_setting("announcement:release").unwrap().is_none());
    }
}
