use std::net::SocketAddr;

use serde::{Deserialize, Serialize};

/// A post identifier — BLAKE3 hash of the canonical serialized post
pub type PostId = [u8; 32];

/// A node identifier — ed25519 public key bytes (same as iroh EndpointId)
pub type NodeId = [u8; 32];

/// A posting identity: the signing key a user authors content with.
///
/// In 0.6.3-beta, every device has exactly one posting identity, which is a
/// copy of the network key (so all existing content keeps verifying). From
/// 0.6.4-beta onward, users can hold multiple posting identities as
/// simultaneously-active personas. The network key stays hidden — peers
/// never learn which network key holds which posting key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PostingIdentity {
    /// ed25519 public key (also the author field on signed content).
    pub node_id: NodeId,
    /// ed25519 secret seed.
    pub secret_seed: [u8; 32],
    /// User-facing label for this persona. Empty string if unset.
    pub display_name: String,
    /// Creation timestamp (ms since epoch).
    pub created_at: u64,
}

/// A public post on the network
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Post {
    /// Author's node ID (ed25519 public key bytes)
    pub author: NodeId,
    /// Post text content
    pub content: String,
    /// Media attachments (empty for text-only posts)
    #[serde(default)]
    pub attachments: Vec<Attachment>,
    /// Unix timestamp in milliseconds
    pub timestamp_ms: u64,
    /// FoF Layer 2: author-signed snapshot of the comment-gating
    /// state at publish time. Carries wrap_slots, pub_post_set, and the
    /// slot_binder_nonce. `None` on posts without FoF comment gating.
    /// Covered by `PostId = BLAKE3(Post)` so any forgery is detectable.
    /// Revocations and access-grants arrive later as engagement diffs
    /// against the local BlobHeader copy; this field is the snapshot at
    /// t=0, not the live mutable state.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fof_gating: Option<FoFCommentGating>,
    /// FoF Layer 4: optional pointer to a post this one supersedes.
    /// Used by the "re-issue with narrower access" path (advanced
    /// rotation). Readers may display "this is a re-issued version
    /// of an earlier post" + offer to view the original if still
    /// cached. Covered by PostId since it's part of the signed Post.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub supersedes_post_id: Option<PostId>,
}

/// A reference to a media blob attached to a post
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Attachment {
    /// BLAKE3 hash of the blob data
    pub cid: [u8; 32],
    /// MIME type (e.g. "image/jpeg")
    pub mime_type: String,
    /// Size of the blob in bytes
    pub size_bytes: u64,
}

/// Public profile — plaintext, synced to all peers
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PublicProfile {
    pub node_id: NodeId,
    pub display_name: String,
    /// Short bio text (can be empty)
    #[serde(default)]
    pub bio: String,
    /// Unix timestamp in milliseconds of last update
    pub updated_at: u64,
    /// Anchor nodes this peer advertises for reachability
    #[serde(default)]
    pub anchors: Vec<NodeId>,
    /// Up to 10 currently-connected peer NodeIds (for 11-needle worm search)
    #[serde(default)]
    pub recent_peers: Vec<NodeId>,
    /// Bilateral preferred peer NodeIds (stable relay hubs)
    #[serde(default)]
    pub preferred_peers: Vec<NodeId>,
    /// Whether display_name/bio are visible to non-circle peers
    #[serde(default = "default_true")]
    pub public_visible: bool,
    /// Avatar blob CID (BLAKE3 hash)
    #[serde(default)]
    pub avatar_cid: Option<[u8; 32]>,
}

impl PublicProfile {
    /// Return a copy with persona-level display data (display_name, bio,
    /// avatar_cid) stripped, leaving only the routing metadata (anchors,
    /// recent_peers, preferred_peers). v0.6.1 broadcasts the profile under
    /// the network NodeId; attaching a human-readable name to that key would
    /// correlate the network endpoint to a specific person. Persona display
    /// data will travel via signed posts from v0.6.2 onward.
    pub fn sanitized_for_network_broadcast(&self) -> Self {
        let mut p = self.clone();
        p.display_name = String::new();
        p.bio = String::new();
        p.avatar_cid = None;
        p
    }
}

fn default_true() -> bool {
    true
}

/// Per-circle profile variant — encrypted with the circle's group key
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CircleProfile {
    pub author: NodeId,
    pub circle_name: String,
    pub display_name: String,
    pub bio: String,
    pub avatar_cid: Option<[u8; 32]>,
    pub updated_at: u64,
}

/// Visibility of a follow relationship
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum FollowVisibility {
    /// Shared in gossip with all peers
    Public,
    /// Never shared unless encrypted for a circle (future)
    Private,
}

impl Default for FollowVisibility {
    fn default() -> Self {
        Self::Public
    }
}

/// Full peer record stored in the database
#[derive(Debug, Clone)]
pub struct PeerRecord {
    pub node_id: NodeId,
    pub addresses: Vec<SocketAddr>,
    pub last_seen: u64,
    pub introduced_by: Option<NodeId>,
    pub is_anchor: bool,
    pub first_seen: u64,
}

/// Compact peer info exchanged during gossip (address-free since sync/6)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GossipPeerInfo {
    pub node_id: NodeId,
    pub is_anchor: bool,
}

/// Worm identifier for deduplication
pub type WormId = [u8; 16];

/// Result of a worm lookup for a single target
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WormResult {
    pub node_id: NodeId,
    pub addresses: Vec<String>,
    pub reporter: NodeId,
    pub freshness_ms: u64,
    pub post_holder: Option<NodeId>,
    pub blob_holder: Option<NodeId>,
}

// --- Encryption / Circles ---

/// Circle name (unique per node)
pub type CircleId = String;

/// Group identifier — BLAKE3 hash of the initial group public key
pub type GroupId = [u8; 32];

/// Group key epoch — incremented on each key rotation
pub type GroupEpoch = u64;

/// A group's private key wrapped for a specific member
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GroupMemberKey {
    pub member: NodeId,
    pub epoch: GroupEpoch,
    /// 60 bytes: nonce(12) || encrypted_group_seed(32) || tag(16)
    pub wrapped_group_key: Vec<u8>,
}

/// A group key record (circle ↔ group key binding).
///
/// v0.6.2: `canonical_root_post_id` distinguishes **groups** (many-way,
/// anchored at a public root post; any member can post) from **circles**
/// (one-way, admin-only, `None`). The encryption primitives are identical;
/// the flag is a UX + query hint so UIs can cluster group posts under
/// their root.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupKeyRecord {
    pub group_id: GroupId,
    pub circle_name: String,
    pub epoch: GroupEpoch,
    pub group_public_key: [u8; 32],
    pub admin: NodeId,
    pub created_at: u64,
    /// When set, this record represents a group rooted at the given public
    /// post. When `None`, the record is a traditional circle.
    #[serde(default)]
    pub canonical_root_post_id: Option<PostId>,
}

/// Visibility of a post — separate from Post struct so it doesn't affect PostId
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum PostVisibility {
    Public,
    Encrypted { recipients: Vec<WrappedKey> },
    GroupEncrypted {
        group_id: GroupId,
        epoch: GroupEpoch,
        /// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16)
        wrapped_cek: Vec<u8>,
    },
    /// FoF Layer 3 (Mode 1): post body is encrypted under the CEK
    /// carried in the post's `fof_gating.wrap_slots`. Tag variant only —
    /// the actual gating data (slot_binder_nonce / pub_post_set /
    /// wrap_slots) lives in `Post.fof_gating` so Mode 1 and Mode 2
    /// share a single home for the FoF state.
    ///
    /// Invariant: when visibility is FoFClosed, `Post.fof_gating` must
    /// be Some. Posts with FoFClosed + None gating are rejected at
    /// receive time.
    FoFClosed,
}

impl Default for PostVisibility {
    fn default() -> Self {
        Self::Public
    }
}

/// A CEK wrapped for a specific recipient via X25519 DH
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WrappedKey {
    pub recipient: NodeId,
    /// 60 bytes: nonce(12) || encrypted_cek(32) || tag(16)
    pub wrapped_cek: Vec<u8>,
}

/// User-facing intent for post visibility (resolved to recipients before encryption).
///
/// A few variants exist for structural distinctions rather than visibility:
/// - `Control` — the post carries a signed operation (delete / visibility
///   update) that receivers apply. Wire visibility is Public; the post is
///   filtered out of feeds and rendered nowhere.
/// - `Profile` — the post carries persona display metadata (display_name,
///   bio, avatar). Wire visibility is Public; the post is not shown in the
///   feed but consulted when rendering the author's name on other posts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum VisibilityIntent {
    Public,
    /// All public follows
    Friends,
    /// Named circle
    Circle(String),
    /// Specific recipients
    Direct(Vec<NodeId>),
    /// Protocol-control post (delete / visibility change).
    Control,
    /// Persona profile post (display_name, bio, avatar).
    Profile,
    /// Encrypted distribution of a group/circle seed to that group's
    /// members. Replaces the v0.6.1 `GroupKeyDistribute` wire push with a
    /// standard encrypted post that propagates via the CDN. Members
    /// decrypt with their posting secret to recover the seed.
    GroupKeyDistribute,
    /// Network-wide announcement (release notice, protocol notice, etc.),
    /// authored only by the hardcoded anchor posting identity and verified
    /// against it on receive. Propagates via the CDN like any other post.
    /// Filtered out of feeds; surfaced only in the Settings / welcome UI.
    Announcement,
}

/// Content payload of a `VisibilityIntent::Profile` post — persona display
/// metadata (display_name, bio, avatar_cid) signed by the posting identity.
/// The post's `author` IS the posting identity; `signature` is an ed25519
/// signature by that identity's secret over the fields (see `crypto::sign_profile`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfilePostContent {
    pub display_name: String,
    #[serde(default)]
    pub bio: String,
    #[serde(default)]
    pub avatar_cid: Option<[u8; 32]>,
    pub timestamp_ms: u64,
    /// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte
    /// layout signed by the posting identity.
    pub signature: Vec<u8>,
    /// FoF Layer 1: HPKE-style anonymous wrapper batch carrying the
    /// voucher's V_me to each non-revoked recipient. `None` on bio posts
    /// that don't issue vouches (e.g., the inaugural empty-state profile
    /// post for a brand-new persona). Bound to this post via the
    /// `bio_post_id` baked into each wrapper's HKDF info.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vouch_grants: Option<VouchGrantBatch>,
    /// FoF Layer 1: monotonic bio-post revision counter for this persona.
    /// Used by receivers to short-circuit the scan via `vouch_bio_scan_cache`.
    /// Increments on every bio publish; 0 for pre-Layer-1 posts (back-compat).
    #[serde(default)]
    pub bio_epoch: u32,
}

/// FoF Layer 1: a batch of per-recipient HPKE-style wrappers carrying
/// the voucher's `V_me` to each currently-vouched persona. Sits inside
/// a `VisibilityIntent::Profile` post. The post's author is the voucher.
///
/// Wire shape: one shared 32B ephemeral X25519 pubkey + N 48-byte
/// wrappers (32B sealed `V_me` + 16B AEAD tag). Wrappers carry no
/// recipient identifier; recipient anonymity is preserved by HPKE key
/// privacy. The HKDF info string includes the bio post id but NEVER
/// a recipient identifier — including one would break key privacy.
///
/// Dummy wrappers are random 48-byte sequences indistinguishable from
/// real ones; they AEAD-fail on every persona. Bucketed padding is
/// applied by the publisher (Layer 3 specifies the bucket boundaries).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VouchGrantBatch {
    /// Shared ephemeral X25519 pubkey for this batch.
    pub batch_eph_pub: [u8; 32],
    /// Epoch of the voucher's `V_me` being distributed in this batch.
    /// All wrappers in the batch carry the same epoch's key.
    pub v_x_epoch: u32,
    /// Random 32B nonce binding this batch's wrappers to this specific
    /// bio publish. Plays the role the spec calls "bio_post_id" in the
    /// HKDF info string — distinct from the actual `PostId` (which is
    /// `BLAKE3(post)` and would be circular here). Recipient-free per
    /// HPKE key-privacy requirements.
    pub bio_pub_nonce: [u8; 32],
    /// Real + dummy wrappers, shuffled. Each entry is exactly 48 bytes
    /// (32B sealed `V_me` + 16B AEAD tag); receivers identify "their"
    /// wrapper by successful AEAD decryption, not by position.
    pub wrappers: Vec<Vec<u8>>,
}

/// Content payload of a `VisibilityIntent::Announcement` post.
///
/// A network-wide announcement signed by the bootstrap anchor's posting
/// identity (see `DEFAULT_ANCHOR_POSTING_ID`). Carries either a release
/// notice (`category == "release"` with `release` populated) or a generic
/// notice (`category == "notice"`). Propagates via the CDN; no direct push.
///
/// Signature is ed25519 over length-prefixed bytes of all fields except
/// `signature` itself — see `crypto::sign_announcement`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnouncementContent {
    /// Machine-readable category: "release" | "notice".
    pub category: String,
    /// Short human-readable title shown in UI.
    pub title: String,
    /// Longer body / changelog notes.
    #[serde(default)]
    pub body: String,
    /// When this announcement was authored (ms). Enforces monotonicity —
    /// older announcements for the same category never overwrite newer
    /// stored state.
    pub timestamp_ms: u64,
    /// Release-specific fields, only set when `category == "release"`.
    #[serde(default)]
    pub release: Option<ReleaseAnnouncement>,
    /// 64-byte ed25519 signature by `DEFAULT_ANCHOR_POSTING_ID`.
    pub signature: Vec<u8>,
}

/// Release-specific subfields of an announcement. Kept separate so a
/// `category == "notice"` post doesn't need these filled in.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseAnnouncement {
    /// "stable" | "beta".
    pub channel: String,
    /// Semver version string, e.g. "0.6.2".
    pub version: String,
    /// URL clients open in the system browser for the download (typically
    /// `https://itsgoin.com/download.html`).
    pub download_url: String,
}

/// Content payload of a `VisibilityIntent::GroupKeyDistribute` post.
/// Wrapped inside a standard `PostVisibility::Encrypted` envelope — members
/// decrypt via `crypto::decrypt_post` with their posting secret, then parse
/// this struct to recover the group seed and metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupKeyDistributionContent {
    pub group_id: GroupId,
    pub circle_name: String,
    pub epoch: GroupEpoch,
    pub group_public_key: [u8; 32],
    pub admin: NodeId,
    #[serde(default)]
    pub canonical_root_post_id: Option<PostId>,
    /// The raw group seed (32 bytes). This is the sensitive field — its
    /// confidentiality is protected by the enclosing encrypted post, which
    /// is wrapped to each member's posting public key.
    pub group_seed: [u8; 32],
}

/// Content payload of a `VisibilityIntent::Control` post, serialized as JSON
/// into the post's content field.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum ControlOp {
    /// Delete post `post_id`. Signature is over `b"ctrl:delete:" || post_id
    /// || timestamp_ms (LE)`, by the target post's author.
    DeletePost {
        post_id: PostId,
        timestamp_ms: u64,
        /// 64-byte ed25519 signature
        signature: Vec<u8>,
    },
    /// Update post `post_id` visibility. Signature is over
    /// `b"ctrl:vis:" || post_id || canonical(new_visibility) || timestamp_ms (LE)`.
    UpdateVisibility {
        post_id: PostId,
        new_visibility: PostVisibility,
        timestamp_ms: u64,
        signature: Vec<u8>,
    },
}

/// A named group of recipients
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Circle {
    pub name: String,
    pub members: Vec<NodeId>,
    pub created_at: u64,
}

// --- Delete / Revocation ---

/// A signed record attesting that an author deleted one of their posts
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteRecord {
    pub post_id: PostId,
    pub author: NodeId,
    pub timestamp_ms: u64,
    /// 64-byte ed25519 signature over post_id bytes
    pub signature: Vec<u8>,
}

/// An update to a post's visibility (new wrapped keys after revocation)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibilityUpdate {
    pub post_id: PostId,
    pub author: NodeId,
    pub visibility: PostVisibility,
}

/// How to handle revoking a recipient's access to past encrypted posts
#[derive(Debug, Clone, Copy)]
pub enum RevocationMode {
    /// Re-wrap the CEK for remaining recipients (fast, but revoked user still has ciphertext)
    SyncAccessList,
    /// Re-encrypt the post with a new CEK (slower, but revoked user can't decrypt even cached copy)
    ReEncrypt,
}

// --- Discovery Protocol v3 (N1/N2/N3 architecture) ---

/// A node reachable through one of our peers (stored in reachable_n2 or reachable_n3)
#[derive(Debug, Clone)]
pub struct ReachableEntry {
    pub reachable_node_id: NodeId,
    pub reporter_node_id: NodeId,
    pub updated_at: u64,
}

/// NAT type classification from STUN probing
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NatType {
    /// Observed addr matches local, or UPnP mapped — fully reachable
    Public,
    /// Same mapped port from multiple STUN servers — cone NAT, hole punch works
    Easy,
    /// Different mapped ports — symmetric NAT, hole punch unlikely to work
    Hard,
    /// Detection failed or not yet run
    Unknown,
}

impl std::fmt::Display for NatType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NatType::Public => write!(f, "public"),
            NatType::Easy => write!(f, "easy"),
            NatType::Hard => write!(f, "hard"),
            NatType::Unknown => write!(f, "unknown"),
        }
    }
}

impl NatType {
    pub fn from_str_label(s: &str) -> Self {
        match s {
            "public" => NatType::Public,
            "easy" => NatType::Easy,
            "hard" => NatType::Hard,
            _ => NatType::Unknown,
        }
    }
}

/// NAT mapping behavior — whether port allocation depends on destination
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NatMapping {
    /// Endpoint-Independent Mapping: same port for all destinations (cone NAT)
    EndpointIndependent,
    /// Endpoint-Dependent Mapping: different port per destination (symmetric NAT)
    EndpointDependent,
    /// Not yet determined
    Unknown,
}

impl std::fmt::Display for NatMapping {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NatMapping::EndpointIndependent => write!(f, "eim"),
            NatMapping::EndpointDependent => write!(f, "edm"),
            NatMapping::Unknown => write!(f, "unknown"),
        }
    }
}

impl NatMapping {
    pub fn from_str_label(s: &str) -> Self {
        match s {
            "eim" => NatMapping::EndpointIndependent,
            "edm" => NatMapping::EndpointDependent,
            _ => NatMapping::Unknown,
        }
    }
}

/// NAT filtering behavior — what inbound packets are allowed through
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NatFiltering {
    /// Full cone or address-restricted — no scanning needed
    Open,
    /// Port-restricted — only exact IP:port gets through, needs scanning
    PortRestricted,
    /// Not yet determined
    Unknown,
}

impl std::fmt::Display for NatFiltering {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NatFiltering::Open => write!(f, "open"),
            NatFiltering::PortRestricted => write!(f, "port_restricted"),
            NatFiltering::Unknown => write!(f, "unknown"),
        }
    }
}

impl NatFiltering {
    pub fn from_str_label(s: &str) -> Self {
        match s {
            "open" => NatFiltering::Open,
            "port_restricted" => NatFiltering::PortRestricted,
            _ => NatFiltering::Unknown,
        }
    }
}

/// Combined NAT profile: mapping + filtering behavior
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NatProfile {
    pub mapping: NatMapping,
    pub filtering: NatFiltering,
}

impl NatProfile {
    pub fn new(mapping: NatMapping, filtering: NatFiltering) -> Self {
        Self { mapping, filtering }
    }

    pub fn unknown() -> Self {
        Self { mapping: NatMapping::Unknown, filtering: NatFiltering::Unknown }
    }

    /// Whether this NAT profile requires port scanning for hole punch
    pub fn needs_scanning(&self) -> bool {
        self.mapping == NatMapping::EndpointDependent
            && self.filtering == NatFiltering::PortRestricted
    }

    /// Convert from legacy NatType (filtering is always Unknown —
    /// "Public" may only be public on IPv6, not IPv4. Use anchor filter probe
    /// to determine filtering reliably.)
    pub fn from_nat_type(nat: NatType) -> Self {
        match nat {
            NatType::Public => Self::new(NatMapping::EndpointIndependent, NatFiltering::Unknown),
            NatType::Easy => Self::new(NatMapping::EndpointIndependent, NatFiltering::Unknown),
            NatType::Hard => Self::new(NatMapping::EndpointDependent, NatFiltering::Unknown),
            NatType::Unknown => Self::unknown(),
        }
    }

    /// Whether standard hole punch should work (no scanning needed)
    pub fn standard_punch_likely(&self, peer: &NatProfile) -> bool {
        // If either side is EIM or Public, standard punch works
        if self.mapping == NatMapping::EndpointIndependent
            || peer.mapping == NatMapping::EndpointIndependent
        {
            return true;
        }
        // Both EDM — only works if at least one has Open filtering
        if self.filtering == NatFiltering::Open || peer.filtering == NatFiltering::Open {
            return true;
        }
        false
    }

    /// Whether scanning should be attempted after standard punch fails.
    /// If both sides have Open filtering, scanning is never needed — standard punch
    /// works regardless of mapping. Scanning is only needed when at least one side
    /// is PortRestricted (or Unknown, conservatively).
    pub fn should_try_scanning(&self, peer: &NatProfile) -> bool {
        // If both sides have confirmed Open filtering, no scanning needed
        if self.filtering == NatFiltering::Open && peer.filtering == NatFiltering::Open {
            return false;
        }
        // Need EDM or Unknown mapping on at least one side, AND at least one side
        // with PortRestricted or Unknown filtering
        let has_edm_or_unknown = self.mapping == NatMapping::EndpointDependent
            || peer.mapping == NatMapping::EndpointDependent
            || self.mapping == NatMapping::Unknown
            || peer.mapping == NatMapping::Unknown;
        let has_restricted_filtering = self.filtering != NatFiltering::Open
            || peer.filtering != NatFiltering::Open;
        has_edm_or_unknown && has_restricted_filtering
    }
}

/// Device profile — determines connection slot budget
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceProfile {
    /// Desktop: 81 local + 20 wide = 101 mesh connections
    Desktop,
    /// Mobile: 10 local + 5 wide = 15 mesh connections
    Mobile,
}

impl DeviceProfile {
    pub fn preferred_slots(&self) -> usize {
        match self {
            DeviceProfile::Desktop => 10,
            DeviceProfile::Mobile => 3,
        }
    }

    pub fn local_slots(&self) -> usize {
        match self {
            DeviceProfile::Desktop => 71,
            DeviceProfile::Mobile => 7,
        }
    }

    pub fn wide_slots(&self) -> usize {
        match self {
            DeviceProfile::Desktop => 20,
            DeviceProfile::Mobile => 5,
        }
    }

    pub fn session_slots(&self) -> usize {
        match self {
            DeviceProfile::Desktop => 20,
            DeviceProfile::Mobile => 5,
        }
    }

    pub fn max_relay_pipes(&self) -> usize {
        match self {
            DeviceProfile::Desktop => 10,
            DeviceProfile::Mobile => 2,
        }
    }
}

/// How a session connection was established
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionReachMethod {
    /// Direct QUIC (address from relay introduction)
    Direct,
    /// Coordinated hole punch via relay introduction
    HolePunch,
    /// Piped through intermediary relay
    Relayed,
}

impl std::fmt::Display for SessionReachMethod {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SessionReachMethod::Direct => write!(f, "direct"),
            SessionReachMethod::HolePunch => write!(f, "holepunch"),
            SessionReachMethod::Relayed => write!(f, "relayed"),
        }
    }
}

/// Slot kind for mesh connections
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PeerSlotKind {
    /// Bilateral preferred connections (Desktop: 10, Mobile: 3)
    Preferred,
    /// Diverse local connections (Desktop: 71, Mobile: 7)
    Local,
    /// Bloom-sourced random distant connections (20 slots)
    Wide,
}

impl std::fmt::Display for PeerSlotKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PeerSlotKind::Preferred => write!(f, "preferred"),
            PeerSlotKind::Local => write!(f, "local"),
            PeerSlotKind::Wide => write!(f, "wide"),
        }
    }
}

impl std::str::FromStr for PeerSlotKind {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "preferred" => Ok(PeerSlotKind::Preferred),
            "local" | "social" => Ok(PeerSlotKind::Local),
            "wide" => Ok(PeerSlotKind::Wide),
            _ => Err(anyhow::anyhow!("unknown slot kind: {}", s)),
        }
    }
}

// --- Social Routing Cache ---

/// How we last reached a social contact
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReachMethod {
    Direct,
    Referral,
    Worm,
    Inbound,
}

impl std::fmt::Display for ReachMethod {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ReachMethod::Direct => write!(f, "direct"),
            ReachMethod::Referral => write!(f, "referral"),
            ReachMethod::Worm => write!(f, "worm"),
            ReachMethod::Inbound => write!(f, "inbound"),
        }
    }
}

impl std::str::FromStr for ReachMethod {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "direct" => Ok(ReachMethod::Direct),
            "referral" => Ok(ReachMethod::Referral),
            "worm" => Ok(ReachMethod::Worm),
            "inbound" => Ok(ReachMethod::Inbound),
            _ => Err(anyhow::anyhow!("unknown reach method: {}", s)),
        }
    }
}

/// Social relationship type. v0.6.2: audience removed; only `Follow` remains.
/// Kept as an enum for forward compatibility (future persona-level relations).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SocialRelation {
    Follow,
}

impl std::fmt::Display for SocialRelation {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SocialRelation::Follow => write!(f, "follow"),
        }
    }
}

impl std::str::FromStr for SocialRelation {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "follow" => Ok(SocialRelation::Follow),
            // Legacy DB values from v0.6.1 and earlier — map to Follow.
            "audience" | "mutual" => Ok(SocialRelation::Follow),
            _ => Err(anyhow::anyhow!("unknown social relation: {}", s)),
        }
    }
}

/// Online/disconnected status
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SocialStatus {
    Online,
    Disconnected,
}

impl std::fmt::Display for SocialStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SocialStatus::Online => write!(f, "online"),
            SocialStatus::Disconnected => write!(f, "disconnected"),
        }
    }
}

impl std::str::FromStr for SocialStatus {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "online" => Ok(SocialStatus::Online),
            "disconnected" => Ok(SocialStatus::Disconnected),
            _ => Err(anyhow::anyhow!("unknown social status: {}", s)),
        }
    }
}

/// A peer with resolved addresses (for peer_addresses in social_routes)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerWithAddress {
    pub n: String,
    pub a: Vec<String>,
}

// --- CDN Manifest Layer ---

/// Lightweight post reference in a manifest
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManifestEntry {
    pub post_id: PostId,
    pub timestamp_ms: u64,
    pub has_attachments: bool,
}

/// Author-signed manifest — cannot be forged by hosts. Max 256KB serialized.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorManifest {
    /// The post this manifest is anchored to
    pub post_id: PostId,
    pub author: NodeId,
    /// Author's N+10:A (ip:port strings)
    pub author_addresses: Vec<String>,
    /// Original post creation time (ms)
    pub created_at: u64,
    /// When manifest was last updated (ms)
    pub updated_at: u64,
    /// Up to 10 posts before this one
    pub previous_posts: Vec<ManifestEntry>,
    /// Up to 10 posts after (grows via updates)
    pub following_posts: Vec<ManifestEntry>,
    /// ed25519 over canonical digest
    pub signature: Vec<u8>,
}

/// CDN manifest traveling with blobs (author-signed part + host metadata)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdnManifest {
    pub author_manifest: AuthorManifest,
    /// Serving host's NodeId
    pub host: NodeId,
    /// Serving host's N+10:A
    pub host_addresses: Vec<String>,
    /// Who the host got it from
    pub source: NodeId,
    /// Source's N+10:A
    pub source_addresses: Vec<String>,
    /// How many downstream this host has
    pub downstream_count: u32,
}

/// Cached routing info for a social contact
#[derive(Debug, Clone)]
pub struct SocialRouteEntry {
    pub node_id: NodeId,
    pub addresses: Vec<SocketAddr>,
    pub peer_addresses: Vec<PeerWithAddress>,
    pub relation: SocialRelation,
    pub status: SocialStatus,
    pub last_connected_ms: u64,
    pub last_seen_ms: u64,
    pub reach_method: ReachMethod,
    /// 2-layer preferred peer tree (~100 nodes) for fast relay candidate search
    pub preferred_tree: Vec<NodeId>,
}

// --- Engagement System ---

/// Maximum engagement header size before splitting oldest comments into a separate post
pub const BLOB_HEADER_ENGAGEMENT_THRESHOLD: usize = 16 * 1024;

/// A reaction to a post
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Reaction {
    /// Who reacted
    pub reactor: NodeId,
    /// Emoji string (e.g. "👍", "❤️")
    pub emoji: String,
    /// Which post
    pub post_id: PostId,
    /// When the reaction was created (ms)
    pub timestamp_ms: u64,
    /// If private: X25519-encrypted payload (only author can decrypt)
    #[serde(default)]
    pub encrypted_payload: Option<String>,
    /// Tombstone timestamp — if set, this reaction has been soft-deleted
    #[serde(default)]
    pub deleted_at: Option<u64>,
    /// ed25519 signature over BLAKE3(reactor || post_id || emoji || timestamp_ms)
    #[serde(default)]
    pub signature: Vec<u8>,
}

/// An inline comment on a post.
///
/// v0.6.2 adds `ref_post_id`: when present, `content` is a short preview
/// string and the full comment body (long text, attachments, rich formatting)
/// lives in a separate referenced Post authored by the commenter. Clients
/// pull the referenced post lazily when rendering the expanded view.
/// When `ref_post_id` is `None`, `content` is the complete comment text
/// (the v0.6.1 shape).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InlineComment {
    /// Comment author
    pub author: NodeId,
    /// Which post this comment is on
    pub post_id: PostId,
    /// Either the full comment text (short comments) or a short preview of
    /// the referenced post (when `ref_post_id` is set).
    ///
    /// On FoF-policy posts this field is empty — the body lives encrypted
    /// in `encrypted_payload`. Non-FoF readers see no text at all.
    pub content: String,
    /// When the comment was created (ms)
    pub timestamp_ms: u64,
    /// ed25519 signature. Binds author/post_id/content/timestamp_ms, plus
    /// `ref_post_id` when present. See `crypto::sign_comment`.
    pub signature: Vec<u8>,
    /// Tombstone timestamp — if set, this comment has been soft-deleted
    #[serde(default)]
    pub deleted_at: Option<u64>,
    /// Optional reference to a full-content Post (long body + attachments).
    /// When set, `content` is a preview; readers fetch the referenced post
    /// for the expanded view.
    #[serde(default)]
    pub ref_post_id: Option<PostId>,
    /// FoF Layer 2: index into the parent post's `pub_post_set`
    /// identifying which voucher-chain signed this comment. `None` on
    /// non-FoF comments. CDN propagation nodes verify `group_sig`
    /// against `pub_post_set[pub_x_index]` before forwarding.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pub_x_index: Option<u32>,
    /// FoF Layer 2: 64-byte ed25519 signature under priv_x over
    /// `(encrypted_payload || parent_post_id || pub_x_index)`. Verified
    /// at CDN-level against `pub_post_set[pub_x_index]`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group_sig: Option<Vec<u8>>,
    /// FoF Layer 2: ChaCha20-Poly1305 ciphertext under CEK_comments
    /// (derived from CEK via HKDF). Plaintext is the JSON-encoded
    /// comment body + optional vouch_mac + optional parent_comment_id.
    /// Non-FoF observers see only ciphertext + sigs — body is opaque.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub encrypted_payload: Option<Vec<u8>>,
}

/// Permission level for comments on a post
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum CommentPermission {
    /// Anyone can comment
    Public,
    /// Only people the author follows publicly can comment.
    /// Renamed from `AudienceOnly` in v0.6.2 when audience was removed.
    FollowersOnly,
    /// Comments disabled
    None,
    /// FoF Layer 2: commenter must hold one of the V_x keys in the
    /// author's keyring (own V_me + every V_x they received). The author
    /// publishes pub_post_set + wrap_slots in the post; commenters trial-
    /// decrypt to unlock priv_x for signing. CDN nodes verify the
    /// comment's group_sig + pub_x_index before forwarding — kills the
    /// bandwidth-DoS attack a single admitted FoF member could mount.
    FriendsOfFriends,
}

impl Default for CommentPermission {
    fn default() -> Self {
        Self::Public
    }
}

/// Permission level for reactions on a post
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ReactPermission {
    /// Public reactions visible to all
    Public,
    /// Private reactions (encrypted, only author sees)
    Private,
    /// Both public and private allowed
    Both,
    /// Reactions disabled
    None,
}

impl Default for ReactPermission {
    fn default() -> Self {
        Self::Both
    }
}

/// Moderation mode for comments
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ModerationMode {
    /// Author maintains a blocklist of users
    AuthorBlocklist,
    /// Only people the author follows publicly can engage.
    /// Renamed from `AudienceOnly` in v0.6.2.
    FollowersOnly,
}

impl Default for ModerationMode {
    fn default() -> Self {
        Self::AuthorBlocklist
    }
}

/// FoF Layer 2: per-V_x wrap slot in a post header. Dual-derived so
/// one successful AEAD-open yields both the read CEK and the per-V_x
/// signing seed. Real slots and dummy padding slots are byte-identical
/// (98 bytes each); receivers identify "their" slot by successful
/// AEAD decryption, not by position.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WrapSlot {
    /// 2-byte HMAC prefix. Receivers precompute one per held V_x; the
    /// scan iterates only slots whose prefilter matches.
    pub prefilter_tag: [u8; 2],
    /// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "read"); 48B
    /// (32B sealed CEK + 16B tag).
    pub read_ciphertext: Vec<u8>,
    /// AEAD ciphertext under KDF(V_x, slot_binder_nonce, "sign"); 48B
    /// (32B sealed priv_x ed25519 seed + 16B tag).
    pub sign_ciphertext: Vec<u8>,
}

/// FoF Layer 2: author-signed revocation entry. When a post-holder
/// receives a valid revocation diff, it deletes all locally-stored
/// comments signed by `revoked_pub_x` AND removes the entry from its
/// local pub_post_set, then forwards the diff. Retroactive cleanup.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RevocationEntry {
    /// The pub_x being revoked. Must be in the post's pub_post_set
    /// at the time the diff is processed.
    pub revoked_pub_x: [u8; 32],
    /// ms since epoch.
    pub revoked_at_ms: u64,
    /// Opaque to CDN; used by author UI to display the reason.
    pub reason_code: u8,
    /// 64-byte ed25519 signature by the post author over
    /// (post_id || revoked_pub_x || revoked_at_ms || reason_code).
    pub author_sig: Vec<u8>,
}

/// FoF Layer 2: the author-published gating block embedded in a
/// FoF-comment-policy post. Carries the wrap slots + the matching
/// pub_post_set + the slot_binder_nonce. The `revocation_list` is
/// initially empty; revocation diffs append over the post's lifetime.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FoFCommentGating {
    /// Random 32B nonce. Plays the spec's "post_id in HKDF info" role
    /// without circularity (PostId = BLAKE3(post) depends on this field).
    pub slot_binder_nonce: [u8; 32],
    /// All admitted pub_x's, 1:1 with `wrap_slots` (including dummies).
    /// Order is randomized at publish; access-grants append at the tail
    /// (Layer 3 resolved decision — pub_x_index stability matters more
    /// than the small tail-positional-recency leak).
    pub pub_post_set: Vec<[u8; 32]>,
    /// Real wrap slots + dummy slots, shuffled at publish. 1:1 with
    /// `pub_post_set`.
    pub wrap_slots: Vec<WrapSlot>,
    /// Initially empty. Receivers accumulate revocations as diffs
    /// arrive; the on-wire t=0 snapshot is empty.
    #[serde(default)]
    pub revocation_list: Vec<RevocationEntry>,
}

/// Author-controlled engagement policy for a post
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommentPolicy {
    pub allow_comments: CommentPermission,
    pub allow_reacts: ReactPermission,
    pub moderation: ModerationMode,
    /// Blocked NodeIds — rejected from commenting/reacting
    #[serde(default)]
    pub blocklist: Vec<NodeId>,
}

impl Default for CommentPolicy {
    fn default() -> Self {
        Self {
            allow_comments: CommentPermission::default(),
            allow_reacts: ReactPermission::default(),
            moderation: ModerationMode::default(),
            blocklist: Vec::new(),
        }
    }
}

/// Incremental engagement operation (sent in BlobHeaderDiff)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum BlobHeaderDiffOp {
    AddReaction(Reaction),
    RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId },
    AddComment(InlineComment),
    EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String },
    DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 },
    SetPolicy(CommentPolicy),
    ThreadSplit { new_post_id: PostId },
    /// Write an encrypted receipt slot (64 bytes encrypted data)
    WriteReceiptSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
    /// Write an encrypted comment slot (256 bytes encrypted data)
    WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
    /// Add new encrypted comment slots (each 256 bytes)
    AddCommentSlots { post_id: PostId, count: u32, slots: Vec<Vec<u8>> },
    /// FoF Layer 2: author revokes a pub_x from a FoF-gated post.
    /// Propagation nodes verify author_sig, drop locally-stored
    /// comments by the revoked pub_x, record the revocation, and
    /// forward. Retroactive + idempotent.
    FoFRevocation {
        post_id: PostId,
        revoked_pub_x: [u8; 32],
        revoked_at_ms: u64,
        reason_code: u8,
        /// 64-byte ed25519 sig by post author over
        /// (post_id || revoked_pub_x || revoked_at_ms_le || reason_code).
        author_sig: Vec<u8>,
    },
    /// FoF Layer 2: author retroactively widens read access on a
    /// FoF-gated post by appending a new (pub_x, wrap_slot) pair. The
    /// newly-vouched persona can now decrypt and comment on the post
    /// without a full re-issue. Append-only at the tail per Layer 3.
    FoFAccessGrant {
        post_id: PostId,
        new_pub_x: [u8; 32],
        new_wrap_slot: WrapSlot,
        granted_at_ms: u64,
        /// 64-byte ed25519 sig by post author over
        /// (post_id || new_pub_x || canonical(new_wrap_slot) || granted_at_ms_le).
        author_sig: Vec<u8>,
    },
    /// FoF Layer 4: in-place wrap_slot replacement for leaked-V_me
    /// scenarios. The author re-seals the slot at `slot_index` under a
    /// fresh V_x (or a different held V_x), invalidating the leaked
    /// key's read access to this specific post. The corresponding
    /// pub_x in pub_post_set is also replaced so future comments must
    /// sign under the new keypair. Comments signed under the OLD
    /// pub_x at this slot are NOT auto-deleted by this op — use a
    /// revocation diff alongside if comment cleanup is desired.
    FoFKeyBurn {
        post_id: PostId,
        slot_index: u32,
        new_pub_x: [u8; 32],
        new_wrap_slot: WrapSlot,
        burned_at_ms: u64,
        /// 64-byte ed25519 sig by post author over
        /// (post_id || slot_index_le || new_pub_x || canonical(new_wrap_slot) || burned_at_ms_le).
        author_sig: Vec<u8>,
    },
    /// Unknown ops from newer protocol versions — silently ignored
    #[serde(other)]
    Unknown,
}

/// Aggregated engagement header for a post (stored locally, propagated as diffs)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobHeader {
    pub post_id: PostId,
    pub author: NodeId,
    pub reactions: Vec<Reaction>,
    pub comments: Vec<InlineComment>,
    pub policy: CommentPolicy,
    pub updated_at: u64,
    /// PostIds of split-off comment overflow posts
    #[serde(default)]
    pub thread_splits: Vec<PostId>,
    /// Encrypted receipt slots (each 64 bytes) — only for encrypted posts
    #[serde(default)]
    pub receipt_slots: Vec<Vec<u8>>,
    /// Encrypted comment slots (each 256 bytes) — only for encrypted posts
    #[serde(default)]
    pub comment_slots: Vec<Vec<u8>>,
    /// Original author NodeId before post merge (set during cross-identity import)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prior_author: Option<NodeId>,
}

/// Receipt slot state byte values
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ReceiptState {
    Empty = 0,
    Delivered = 1,
    Seen = 2,
    Reacted = 3,
}

impl ReceiptState {
    pub fn from_u8(v: u8) -> Self {
        match v {
            1 => ReceiptState::Delivered,
            2 => ReceiptState::Seen,
            3 => ReceiptState::Reacted,
            _ => ReceiptState::Empty,
        }
    }

    pub fn from_str_label(s: &str) -> Self {
        match s {
            "delivered" => ReceiptState::Delivered,
            "seen" => ReceiptState::Seen,
            "reacted" => ReceiptState::Reacted,
            _ => ReceiptState::Empty,
        }
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            ReceiptState::Empty => "empty",
            ReceiptState::Delivered => "delivered",
            ReceiptState::Seen => "seen",
            ReceiptState::Reacted => "reacted",
        }
    }
}

/// Decrypted receipt slot data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptSlotData {
    pub slot_index: u32,
    pub node_id: Option<NodeId>,
    pub state: ReceiptState,
    pub timestamp_ms: u64,
    /// Emoji string (only set when state == Reacted)
    pub emoji: Option<String>,
}

/// Decrypted comment slot data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentSlotData {
    pub slot_index: u32,
    pub author: NodeId,
    pub timestamp_ms: u64,
    pub content: String,
}

/// Links a split-off comment post back to its original parent
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThreadMeta {
    /// The split-off post's ID
    pub post_id: PostId,
    /// The original parent post this was split from
    pub parent_post_id: PostId,
}

// --- Active CDN Replication ---

/// Device role for CDN replication budgets and pull ordering.
/// Determines how aggressively a device replicates content and how much
/// delivery bandwidth it reserves.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceRole {
    /// Phone/tablet — high consumer, intermittent availability
    Intermittent,
    /// Desktop/laptop — moderate provider, usually available during sessions
    Available,
    /// Anchor/server — persistent availability, reserved for web serving
    Persistent,
}

impl DeviceRole {
    /// Hourly replication budget in bytes (content we pull to cache for others).
    pub fn replication_limit(&self) -> u64 {
        match self {
            DeviceRole::Intermittent => 100 * 1024 * 1024,  // 100 MB/hr — phones rarely get replication requests
            DeviceRole::Available => 200 * 1024 * 1024,     // 200 MB/hr — desktops are workhorses
            DeviceRole::Persistent => 200 * 1024 * 1024,    // 200 MB/hr — anchors save capacity for web serving
        }
    }

    /// Hourly delivery budget in bytes (content we serve when peers ask).
    pub fn delivery_limit(&self) -> u64 {
        match self {
            DeviceRole::Intermittent => 1024 * 1024 * 1024, // 1 GB/hr — phones serve heavily when online (OS data limits apply)
            DeviceRole::Available => 2048 * 1024 * 1024,    // 2 GB/hr — desktops on cheap home bandwidth
            DeviceRole::Persistent => 1024 * 1024 * 1024,   // 1 GB/hr — anchors reserved for web/browser serving
        }
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            DeviceRole::Intermittent => "intermittent",
            DeviceRole::Available => "available",
            DeviceRole::Persistent => "persistent",
        }
    }

    pub fn from_str_label(s: &str) -> Self {
        match s {
            "intermittent" => DeviceRole::Intermittent,
            "available" => DeviceRole::Available,
            "persistent" => DeviceRole::Persistent,
            _ => DeviceRole::Available,
        }
    }
}

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