//! Signal scope: where a signal lives and whether it may ever leave the node.
//!
//! [`SignalScope`] is the load-bearing M9 primitive. The default is
//! [`SignalScope::Local`], and local-scoped signals **never ship** to a
//! community and are **never purged** by community operations — this is the
//! "local profile remains intact" guarantee that every M9/M10 surface upholds.
//!
//! On the WAL wire the scope is persisted as a single discriminant byte (see
//! [`SignalScope::discriminant`]). The community id carried by
//! [`SignalScope::Community`] is *not* stored in the base event record: which
//! community a share-eligible event flows into is decided at ship time from the
//! writer's [memberships](crate::governance::membership), so the persisted byte
//! records only the scope *class*. Decoding a community byte therefore yields
//! `Community(CommunityId::NONE)`; the concrete id lives in the in-memory API
//! value and in the community ledger's keys, not in the per-event envelope.

use crate::wal::error::WalError;

/// Identifies a community personalization overlay.
///
/// `CommunityId(0)` ([`CommunityId::NONE`]) is reserved to mean "no community"
/// and is what a community scope decodes to when read back from the WAL (the
/// concrete id is membership-driven, not stored per event).
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct CommunityId(pub u64);

impl CommunityId {
    /// Sentinel for "no community".
    pub const NONE: Self = Self(0);

    /// The raw community id.
    #[must_use]
    pub const fn as_u64(self) -> u64 {
        self.0
    }

    /// Whether this is the reserved [`CommunityId::NONE`] sentinel.
    #[must_use]
    pub const fn is_none(self) -> bool {
        self.0 == 0
    }
}

impl std::fmt::Display for CommunityId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "c{}", self.0)
    }
}

/// Scope of a signal: where it lives and whether it may ever ship.
///
/// - [`Local`](SignalScope::Local) — default; never ships, never community-purged.
/// - [`Community`](SignalScope::Community) — eligible to contribute to community
///   overlays subject to the writer's [`SharePolicy`](super::share_policy::SharePolicy).
/// - [`Session`](SignalScope::Session) — scoped to one agent session.
/// - [`Agent`](SignalScope::Agent) — attributed to an agent; removable by agent scope.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
)]
pub enum SignalScope {
    /// The default. Stays on the local node forever.
    #[default]
    Local,
    /// Contributes to a community overlay when share-eligible.
    Community(CommunityId),
    /// Scoped to an agent session.
    Session,
    /// Attributed to an agent.
    Agent,
}

impl SignalScope {
    /// The 1-byte wire discriminant for this scope class.
    #[must_use]
    pub const fn discriminant(self) -> u8 {
        match self {
            Self::Local => 0,
            Self::Community(_) => 1,
            Self::Session => 2,
            Self::Agent => 3,
        }
    }

    /// Decode a scope class from its wire discriminant.
    ///
    /// A community byte decodes to `Community(CommunityId::NONE)` — the concrete
    /// id is membership-driven and not stored in the base event (see module docs).
    ///
    /// # Errors
    ///
    /// Returns [`WalError::Corruption`] for any discriminant outside `0..=3`.
    pub fn from_discriminant(b: u8) -> Result<Self, WalError> {
        match b {
            0 => Ok(Self::Local),
            1 => Ok(Self::Community(CommunityId::NONE)),
            2 => Ok(Self::Session),
            3 => Ok(Self::Agent),
            other => Err(WalError::Corruption {
                message: format!("invalid SignalScope discriminant: {other}"),
            }),
        }
    }

    /// Whether an event of this scope is ever eligible to leave the node.
    ///
    /// `Local` is never shippable; everything else *may* ship subject to a
    /// [`SharePolicy`](super::share_policy::SharePolicy).
    #[must_use]
    pub const fn is_shippable(self) -> bool {
        !matches!(self, Self::Local)
    }

    /// Whether this scope is a community scope.
    #[must_use]
    pub const fn is_community(self) -> bool {
        matches!(self, Self::Community(_))
    }

    /// The concrete community id, if this is a community scope.
    #[must_use]
    pub const fn community_id(self) -> Option<CommunityId> {
        match self {
            Self::Community(id) => Some(id),
            _ => None,
        }
    }
}

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

    #[test]
    fn default_is_local() {
        assert_eq!(SignalScope::default(), SignalScope::Local);
    }

    #[test]
    fn discriminant_roundtrip_all_classes() {
        for scope in [
            SignalScope::Local,
            SignalScope::Community(CommunityId(7)),
            SignalScope::Session,
            SignalScope::Agent,
        ] {
            let b = scope.discriminant();
            let decoded = SignalScope::from_discriminant(b).unwrap();
            // Community loses its id on the wire (decodes to NONE), so compare classes.
            assert_eq!(decoded.discriminant(), b);
        }
    }

    #[test]
    fn community_decodes_to_none() {
        let decoded = SignalScope::from_discriminant(1).unwrap();
        assert_eq!(decoded, SignalScope::Community(CommunityId::NONE));
    }

    #[test]
    fn invalid_discriminant_is_corruption() {
        assert!(SignalScope::from_discriminant(4).is_err());
        assert!(SignalScope::from_discriminant(255).is_err());
    }

    #[test]
    fn local_is_never_shippable() {
        assert!(!SignalScope::Local.is_shippable());
        assert!(SignalScope::Community(CommunityId(1)).is_shippable());
        assert!(SignalScope::Session.is_shippable());
        assert!(SignalScope::Agent.is_shippable());
    }

    #[test]
    fn community_accessors() {
        let c = SignalScope::Community(CommunityId(42));
        assert!(c.is_community());
        assert_eq!(c.community_id(), Some(CommunityId(42)));

        assert!(!SignalScope::Local.is_community());
        assert_eq!(SignalScope::Local.community_id(), None);
    }

    #[test]
    fn community_id_display_and_none() {
        assert_eq!(CommunityId(5).to_string(), "c5");
        assert!(CommunityId::NONE.is_none());
        assert!(!CommunityId(1).is_none());
        assert_eq!(CommunityId(9).as_u64(), 9);
    }
}
