prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use alloc::vec::Vec;
use core::num::NonZeroUsize;

use crate::identity::IdentityHash;
use crate::interfaces::InterfaceGravity;
use crate::units::{DurationMillis, InstantMillis};

use super::StampCost;

pub const DISCOVERY_UNKNOWN_AFTER: DurationMillis = DurationMillis(24 * 60 * 60 * 1_000);
pub const DISCOVERY_STALE_AFTER: DurationMillis = DurationMillis(3 * 24 * 60 * 60 * 1_000);
pub const DISCOVERY_EXPIRES_AFTER: DurationMillis = DurationMillis(7 * 24 * 60 * 60 * 1_000);

#[derive(Debug, Clone, PartialEq)]
pub enum InterfaceDiscoveryPolicy {
    Disabled,
    Enabled(EnabledDiscoveryPolicy),
}

impl InterfaceDiscoveryPolicy {
    pub fn enabled(
        required_stamp_cost: StampCost,
        sources: DiscoverySourcePolicy,
        auto_connect: AutoConnectPolicy,
        auto_connect_routing: AutoConnectRoutingPolicy,
    ) -> Self {
        Self::Enabled(EnabledDiscoveryPolicy {
            required_stamp_cost,
            sources,
            auto_connect,
            auto_connect_routing,
        })
    }

    pub const fn enabled_policy(&self) -> Option<&EnabledDiscoveryPolicy> {
        match self {
            Self::Disabled => None,
            Self::Enabled(policy) => Some(policy),
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct EnabledDiscoveryPolicy {
    required_stamp_cost: StampCost,
    sources: DiscoverySourcePolicy,
    auto_connect: AutoConnectPolicy,
    auto_connect_routing: AutoConnectRoutingPolicy,
}

impl EnabledDiscoveryPolicy {
    pub const fn required_stamp_cost(&self) -> StampCost {
        self.required_stamp_cost
    }

    pub const fn sources(&self) -> &DiscoverySourcePolicy {
        &self.sources
    }

    pub const fn auto_connect(&self) -> &AutoConnectPolicy {
        &self.auto_connect
    }

    pub const fn auto_connect_gravity(&self) -> InterfaceGravity {
        self.auto_connect_routing.gravity
    }

    pub const fn auto_connect_announces_to_internal(&self) -> bool {
        self.auto_connect_routing.announces_to_internal
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoConnectRoutingPolicy {
    pub gravity: InterfaceGravity,
    pub announces_to_internal: bool,
}

#[derive(Debug, Clone, PartialEq)]
pub enum DiscoverySourcePolicy {
    Open,
    Restricted(DiscoverySourceAllowList),
}

#[derive(Debug, Clone, PartialEq)]
pub struct DiscoverySourceAllowList {
    sources: Vec<IdentityHash>,
}

impl DiscoverySourcePolicy {
    pub fn from_sources(sources: Vec<IdentityHash>) -> Self {
        if sources.is_empty() {
            Self::Open
        } else {
            Self::Restricted(DiscoverySourceAllowList { sources })
        }
    }

    pub fn accepts(&self, source: &IdentityHash) -> bool {
        match self {
            Self::Open => true,
            Self::Restricted(sources) => sources.contains(source),
        }
    }

    pub fn allow_list(&self) -> Option<&[IdentityHash]> {
        match self {
            Self::Open => None,
            Self::Restricted(sources) => Some(sources.as_slice()),
        }
    }
}

impl DiscoverySourceAllowList {
    pub fn contains(&self, source: &IdentityHash) -> bool {
        self.sources.contains(source)
    }

    pub fn as_slice(&self) -> &[IdentityHash] {
        &self.sources
    }
}

#[derive(Debug, Clone, PartialEq)]
pub enum AutoConnectPolicy {
    Disabled,
    Limited(NonZeroUsize),
}

impl AutoConnectPolicy {
    pub const fn from_maximum(maximum: usize) -> Self {
        match NonZeroUsize::new(maximum) {
            Some(maximum) => Self::Limited(maximum),
            None => Self::Disabled,
        }
    }

    pub const fn maximum(&self) -> Option<usize> {
        match self {
            Self::Disabled => None,
            Self::Limited(maximum) => Some(maximum.get()),
        }
    }

    pub const fn remaining_slots(&self, connected: usize) -> usize {
        match self.maximum() {
            Some(maximum) => maximum.saturating_sub(connected),
            None => 0,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscoveredInterfaceStatus {
    Available,
    Unknown,
    Stale,
    Expired,
}

pub const fn discovered_interface_status(
    last_heard: InstantMillis,
    now: InstantMillis,
) -> DiscoveredInterfaceStatus {
    let age = now.duration_since(last_heard);
    if age.0 > DISCOVERY_EXPIRES_AFTER.0 {
        DiscoveredInterfaceStatus::Expired
    } else if age.0 > DISCOVERY_STALE_AFTER.0 {
        DiscoveredInterfaceStatus::Stale
    } else if age.0 > DISCOVERY_UNKNOWN_AFTER.0 {
        DiscoveredInterfaceStatus::Unknown
    } else {
        DiscoveredInterfaceStatus::Available
    }
}

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

    fn identity(byte: u8) -> IdentityHash {
        IdentityHash::new([byte; 16])
    }

    #[test]
    fn an_empty_source_list_is_open_and_a_nonempty_list_is_exact() {
        let open = DiscoverySourcePolicy::from_sources(Vec::new());
        assert!(open.accepts(&identity(1)));
        assert_eq!(open.allow_list(), None);

        let restricted = DiscoverySourcePolicy::from_sources(vec![identity(2), identity(3)]);
        assert!(restricted.accepts(&identity(2)));
        assert!(!restricted.accepts(&identity(4)));
        assert_eq!(
            restricted.allow_list(),
            Some([identity(2), identity(3)].as_slice())
        );
    }

    #[test]
    fn an_autoconnect_limit_never_overbooks() {
        let disabled = AutoConnectPolicy::from_maximum(0);
        assert_eq!(disabled.maximum(), None);
        assert_eq!(disabled.remaining_slots(0), 0);

        let limited = AutoConnectPolicy::from_maximum(3);
        assert_eq!(limited.maximum(), Some(3));
        assert_eq!(limited.remaining_slots(1), 2);
        assert_eq!(limited.remaining_slots(3), 0);
        assert_eq!(limited.remaining_slots(9), 0);
    }

    #[test]
    fn record_status_uses_the_reference_threshold_boundaries() {
        let last_heard = InstantMillis(1_000);
        assert!(matches!(
            discovered_interface_status(
                last_heard,
                last_heard.saturating_add(DISCOVERY_UNKNOWN_AFTER),
            ),
            DiscoveredInterfaceStatus::Available,
        ));
        assert!(matches!(
            discovered_interface_status(
                last_heard,
                InstantMillis(last_heard.0 + DISCOVERY_UNKNOWN_AFTER.0 + 1),
            ),
            DiscoveredInterfaceStatus::Unknown,
        ));
        assert!(matches!(
            discovered_interface_status(
                last_heard,
                last_heard.saturating_add(DISCOVERY_STALE_AFTER),
            ),
            DiscoveredInterfaceStatus::Unknown,
        ));
        assert!(matches!(
            discovered_interface_status(
                last_heard,
                InstantMillis(last_heard.0 + DISCOVERY_STALE_AFTER.0 + 1),
            ),
            DiscoveredInterfaceStatus::Stale,
        ));
        assert!(matches!(
            discovered_interface_status(
                last_heard,
                last_heard.saturating_add(DISCOVERY_EXPIRES_AFTER),
            ),
            DiscoveredInterfaceStatus::Stale,
        ));
        assert!(matches!(
            discovered_interface_status(
                last_heard,
                InstantMillis(last_heard.0 + DISCOVERY_EXPIRES_AFTER.0 + 1),
            ),
            DiscoveredInterfaceStatus::Expired,
        ));
        assert!(matches!(
            discovered_interface_status(InstantMillis(5_000), InstantMillis(4_000)),
            DiscoveredInterfaceStatus::Available,
        ));
    }
}