//! Purge / remove-by-scope tombstones (M9p3, reused by M10p3).
//!
//! A [`PurgeTombstone`] is the durable, auditable record that a user's prior
//! community contributions (within an epoch range) were retroactively removed.
//! It is written under `Tag::PurgeTombstone` *before* the in-memory aggregate is
//! rematerialized, so a crash mid-purge replays to the same outcome: on reopen,
//! tombstones are re-applied against the restored community ledger, making the
//! purge deterministic across replay/failover.
//!
//! The tombstone also carries a monotonic `watermark_ns` that community queries
//! surface (`PolicyMetadata::purge_watermark`) so a reader can tell "this result
//! reflects all purges up to time T".

use serde::{Deserialize, Serialize};

use super::{membership::UserId, scope::CommunityId};
use crate::schema::TidalError;

/// Serialize a durable governance row to bytes, mapping any serde failure to
/// [`TidalError::Internal`] tagged with `operation`.
///
/// Both tombstone types share the same durability invariant: a serialize
/// failure must be propagated, never swallowed into empty bytes — an empty
/// value deserializes back as a *missing* purge record, which on crash-replay
/// silently resurrects purged data (the exact privacy/trust failure these
/// durable records exist to prevent). Centralizing the serialize-or-error here
/// keeps that single behavior from drifting between the two call sites; each
/// caller supplies its own `operation` tag so the error context stays precise.
///
/// # Errors
///
/// Returns [`TidalError::Internal`] if serialization fails.
fn to_durable_bytes<T: Serialize>(value: &T, operation: &'static str) -> crate::Result<Vec<u8>> {
    serde_json::to_vec(value).map_err(|e| TidalError::internal(operation, e.to_string()))
}

/// A durable record of a retroactive purge of one user's community
/// contributions over an inclusive epoch range.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PurgeTombstone {
    /// The user whose contributions were removed.
    pub user_id: UserId,
    /// The community the contributions were removed from.
    pub community_id: CommunityId,
    /// First membership epoch removed (inclusive).
    pub epoch_start: u32,
    /// Last membership epoch removed (inclusive).
    pub epoch_end: u32,
    /// Wall-clock ns at which the purge was applied; doubles as the purge
    /// watermark exposed to community queries.
    pub watermark_ns: u64,
}

impl PurgeTombstone {
    /// Construct a tombstone for `user`'s `community` contributions in
    /// `[epoch_start, epoch_end]`.
    #[must_use]
    pub const fn new(
        user_id: UserId,
        community_id: CommunityId,
        epoch_start: u32,
        epoch_end: u32,
        watermark_ns: u64,
    ) -> Self {
        Self {
            user_id,
            community_id,
            epoch_start,
            epoch_end,
            watermark_ns,
        }
    }

    /// The inclusive epoch range this tombstone purges.
    #[must_use]
    pub const fn epoch_range(&self) -> std::ops::RangeInclusive<u32> {
        self.epoch_start..=self.epoch_end
    }

    /// Durable storage suffix: `community_be8 || user_be8 || epoch_start_be4 ||
    /// epoch_end_be4`. Keying by the range makes re-purging the same range
    /// idempotent (it rewrites the same key) while distinct ranges coexist.
    #[must_use]
    pub fn storage_suffix(&self) -> Vec<u8> {
        let mut s = Vec::with_capacity(24);
        s.extend_from_slice(&self.community_id.as_u64().to_be_bytes());
        s.extend_from_slice(&self.user_id.as_u64().to_be_bytes());
        s.extend_from_slice(&self.epoch_start.to_be_bytes());
        s.extend_from_slice(&self.epoch_end.to_be_bytes());
        s
    }

    /// Serialize to durable bytes.
    ///
    /// A serialize failure is propagated as [`TidalError::Internal`] rather than
    /// swallowed into empty bytes: an empty tombstone deserializes back as a
    /// *missing* purge record, which on crash-replay would silently resurrect
    /// purged user data — the exact privacy failure this durable record exists to
    /// prevent. This struct always serializes cleanly in practice.
    ///
    /// # Errors
    ///
    /// Returns [`TidalError::Internal`] if serialization fails.
    pub fn to_bytes(&self) -> crate::Result<Vec<u8>> {
        to_durable_bytes(self, "purge_tombstone_to_bytes")
    }

    /// Deserialize from durable bytes; malformed bytes return `None`.
    ///
    /// This is the clean entry point the on-open replay path uses to rehydrate a
    /// `Tag::PurgeTombstone` row before re-applying it via
    /// [`CommunityLedger::apply_purge_tombstone`](super::community_ledger::CommunityLedger::apply_purge_tombstone).
    #[must_use]
    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
        serde_json::from_slice(bytes).ok()
    }
}

/// A durable record of a remove-by-agent purge: every contribution written by
/// `writer_agent` to `community_id` was removed (M10p3 agent-scope removal).
///
/// The user-scoped [`PurgeTombstone`] keys on `(community, user, epoch_range)`;
/// agent removal cuts across users, so it needs its own tombstone keyed on
/// `(community, writer_agent)`. Written under `Tag::AgentPurgeTombstone` *before*
/// the in-memory `purge_writer` mutation (durability-before-mutation), and
/// replayed on open against the restored community ledger so a crash after the
/// purge but before the next community checkpoint does not resurrect the revoked
/// agent's contributions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentPurgeTombstone {
    /// The community the contributions were removed from.
    pub community_id: CommunityId,
    /// The writer agent whose contributions were removed.
    pub writer_agent: String,
    /// Wall-clock ns at which the purge was applied; doubles as the purge
    /// watermark exposed to community queries.
    pub watermark_ns: u64,
}

impl AgentPurgeTombstone {
    /// Construct a tombstone for `writer_agent`'s `community` contributions.
    #[must_use]
    pub const fn new(community_id: CommunityId, writer_agent: String, watermark_ns: u64) -> Self {
        Self {
            community_id,
            writer_agent,
            watermark_ns,
        }
    }

    /// Durable storage suffix: `community_be8 || writer_agent_utf8`.
    ///
    /// Keying by `(community, writer_agent)` makes re-purging the same agent
    /// idempotent (it rewrites the same key) while distinct agents coexist.
    #[must_use]
    pub fn storage_suffix(&self) -> Vec<u8> {
        let mut s = Vec::with_capacity(8 + self.writer_agent.len());
        s.extend_from_slice(&self.community_id.as_u64().to_be_bytes());
        s.extend_from_slice(self.writer_agent.as_bytes());
        s
    }

    /// Serialize to durable bytes.
    ///
    /// A serialize failure is propagated rather than swallowed into empty bytes:
    /// an empty tombstone deserializes back as a *missing* purge, which on
    /// crash-replay would silently resurrect a revoked agent's data — the exact
    /// trust failure this durable record exists to prevent.
    ///
    /// # Errors
    ///
    /// Returns [`TidalError::Internal`] if serialization fails.
    pub fn to_bytes(&self) -> crate::Result<Vec<u8>> {
        to_durable_bytes(self, "agent_purge_tombstone_to_bytes")
    }

    /// Deserialize from durable bytes; malformed bytes return `None`.
    #[must_use]
    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
        serde_json::from_slice(bytes).ok()
    }
}

/// The scope of a [`remove_from_personalization`] operation (M10p3): which
/// contributions to remove, non-globally.
///
/// [`remove_from_personalization`]: crate::TidalDb::remove_from_personalization
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoveScope {
    /// Remove a user's contributions to a community within an inclusive epoch
    /// range. Equivalent to a retroactive purge.
    CommunityUser {
        /// The user whose contributions to remove.
        user: UserId,
        /// The community to remove from.
        community: CommunityId,
        /// Inclusive membership-epoch range to remove.
        epoch_range: std::ops::RangeInclusive<u32>,
    },
    /// Remove all contributions written by a specific agent to a community
    /// (e.g. after revoking that agent).
    CommunityAgent {
        /// The writer agent whose contributions to remove.
        writer_agent: String,
        /// The community to remove from.
        community: CommunityId,
    },
}

/// Outcome of a purge operation, returned to the caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PurgeReceipt {
    /// Number of per-contributor entries removed from the community aggregate.
    pub contributions_removed: usize,
    /// The purge watermark (wall-clock ns) recorded in the tombstone.
    pub watermark_ns: u64,
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn tombstone_roundtrip() {
        let t = PurgeTombstone::new(UserId(3), CommunityId(7), 0, 2, 123_456);
        let bytes = t.to_bytes().unwrap();
        assert_eq!(PurgeTombstone::from_bytes(&bytes), Some(t.clone()));
        assert_eq!(t.epoch_range(), 0..=2);
    }

    #[test]
    fn storage_suffix_is_stable_for_same_range() {
        let a = PurgeTombstone::new(UserId(1), CommunityId(1), 0, 0, 10);
        let b = PurgeTombstone::new(UserId(1), CommunityId(1), 0, 0, 999);
        // Same (community,user,range) -> same key (idempotent), even if watermark differs.
        assert_eq!(a.storage_suffix(), b.storage_suffix());
    }

    #[test]
    fn distinct_ranges_distinct_suffixes() {
        let a = PurgeTombstone::new(UserId(1), CommunityId(1), 0, 0, 10);
        let b = PurgeTombstone::new(UserId(1), CommunityId(1), 1, 1, 10);
        assert_ne!(a.storage_suffix(), b.storage_suffix());
    }

    #[test]
    fn both_tombstones_serialize_via_shared_helper_and_roundtrip() {
        // Both `to_bytes` paths now route through `to_durable_bytes`; the shared
        // helper must still produce non-empty, round-trippable bytes for each
        // type (the success path the durable log relies on).
        let user = PurgeTombstone::new(UserId(3), CommunityId(7), 0, 2, 123_456);
        let user_bytes = user.to_bytes().expect("valid tombstone serializes");
        assert!(!user_bytes.is_empty());
        assert_eq!(PurgeTombstone::from_bytes(&user_bytes), Some(user));

        let agent = AgentPurgeTombstone::new(CommunityId(7), "agent-x".to_string(), 999);
        let agent_bytes = agent.to_bytes().expect("valid tombstone serializes");
        assert!(!agent_bytes.is_empty());
        assert_eq!(AgentPurgeTombstone::from_bytes(&agent_bytes), Some(agent));
    }

    #[test]
    fn durable_helper_tags_error_context_with_operation() {
        // The helper threads the caller's operation tag into the error context.
        // A non-serializable value (a map with non-string keys, which JSON
        // rejects) drives the error path so we can assert the tag is preserved.
        use std::collections::BTreeMap;
        let bad: BTreeMap<(u8, u8), u8> = BTreeMap::from([((1, 2), 3)]);
        let err = to_durable_bytes(&bad, "unit_test_tag")
            .expect_err("non-string-keyed map is not valid JSON");
        assert!(
            err.to_string().contains("unit_test_tag"),
            "operation tag must survive into the error: {err}"
        );
    }
}
