//! FIFA 17 empty-My-Packs capability negotiation — per-session state machine.
//!
//! This is the Rust production port of the *novel* session/capability logic that
//! was proven live on staging and currently lives in the Python oracle
//! (`fifa17-recon/tools/utas_server.py`, "FIFA17 empty-My-Packs capability
//! negotiation"). Python remains the behavioural **reference/oracle**; this module
//! is the intended production **authority** for the machinery so new FUT session
//! behaviour stops accumulating in Python.
//!
//! ## Scope (deliberately bounded)
//!
//! This module owns the pure state machine only:
//!   * per-login session table keyed by the unique UTAS session id (`X-UT-SID`),
//!   * the single-use `(ip, persona)` launcher→session capability hand-off (pending),
//!   * capability binding (`bound` / `pending` / `ignored-late`),
//!   * the once-per-session empty-My-Packs freeze (`clean-v1` vs `sentinel`),
//!   * TTL reaping and fail-closed rules (unknown / expired / ambiguous / late /
//!     cross-session / sid-ip-mismatch ⇒ sentinel).
//!
//! It has **no** HTTP, Core, or IO dependencies, and it does **not** reproduce the
//! delicate `_pack_body` UTAS wire shaping (utas_server.py:3474 — a type-sensitive,
//! reverse-engineered body where a wrong scalar type silently breaks pack buying).
//! That shaping, the `/ut/auth` persona-adoption, `/store/purchasegroup` catalogue
//! assembly, and the store BUY path remain Python-owned until a separate, carefully
//! differential-tested slice ports them. Wiring these three routes
//! (`/ut/auth` SID, `/openfut/fifa17/capability`, `/store/purchasegroup`) into
//! `openfut-utas-host` against this state machine — without dividing store authority
//! (i.e. porting BUY too, so purchasegroup display and buy validation don't split) —
//! is the remaining bounded gap toward full Rust authority.
//!
//! ## Purity / testability
//!
//! The monotonic clock is injected (`now: f64` seconds) rather than read from a
//! global, so every A–R matrix scenario is a deterministic unit test. SID entropy
//! is likewise injected ([`format_sid`]) — the host supplies a unique 64-bit value;
//! the Python oracle notes uniqueness (not unpredictability) is the requirement.

use std::collections::HashMap;

/// The single capability this negotiation understands.
pub const CAPABILITY_NAME: &str = "empty_mypacks_resolver";
/// The only accepted resolver version (FIFA17 CardsDLL guard at RVA `0x14858`).
pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1;
/// Synthetic non-openable pack id — the universal P2 sentinel. Deliberately ABSENT
/// from the store `PACK_CATALOG`, so it can never be bought/opened/granted.
pub const SENTINEL_PACK_ID: u64 = 65534;
/// A FIFA session is reaped after this many idle seconds.
pub const SESSION_TTL_SECS: f64 = 3600.0;
/// A launcher capability may await its session for this long before expiring.
pub const PENDING_TTL_SECS: f64 = 120.0;

/// The empty-My-Packs store topology, frozen once per session at its first store
/// request and immutable thereafter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreMode {
    /// Universal fallback: emit the synthetic non-openable [`SENTINEL_PACK_ID`] pack
    /// so the client's `mypacks` category resolves and does not crash.
    Sentinel,
    /// Verified patched client: emit NO `mypacks` group; the CardsDLL resolver guard
    /// routes the absent category to Browse.
    CleanV1,
}

impl StoreMode {
    /// The wire token the Python oracle logs/uses (`"sentinel"` / `"clean-v1"`).
    pub fn as_str(self) -> &'static str {
        match self {
            StoreMode::Sentinel => "sentinel",
            StoreMode::CleanV1 => "clean-v1",
        }
    }
}

/// Outcome of a launcher capability registration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegisterOutcome {
    /// Exactly one live, unfrozen, unbound session for `(ip, persona)` existed and
    /// was bound now (the common post-login case).
    Bound,
    /// No session for `(ip, persona)` yet (registration before login): staged as a
    /// single-use pending hand-off.
    Pending,
    /// A session for `(ip, persona)` exists but is frozen or ambiguous (>1 unbound):
    /// NOT staged, so no later/unverified process can inherit it. Fail-closed.
    IgnoredLate,
}

/// Rejection reason for a capability POST body (maps to HTTP 400 at the route).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapabilityError {
    /// Capability name or version is not the supported `empty_mypacks_resolver` v1.
    Unsupported,
}

/// Validate a capability registration request body. Anything but the supported
/// `empty_mypacks_resolver` at the current version is rejected — the session then
/// simply stays on the sentinel fallback (the route records nothing).
pub fn validate_capability(name: &str, version: u32) -> Result<(), CapabilityError> {
    if name == CAPABILITY_NAME && version == EMPTY_MYPACKS_RESOLVER_VERSION {
        Ok(())
    } else {
        Err(CapabilityError::Unsupported)
    }
}

/// Format a unique per-login UTAS session id from a caller-supplied 64-bit value.
/// Same shape/length as the legacy constant; uniqueness — not unpredictability — is
/// what the binding needs, so the host may use any unique source (random or counter).
pub fn format_sid(bits: u64) -> String {
    format!("OPENFUT-SID-{bits:016X}")
}

/// The identity of the synthetic empty-My-Packs sentinel pack (utas_server.py:3671).
/// This is the *identity* only; the full UTAS `purchase[]` entry is produced by the
/// Python `_pack_body` shaping, which is intentionally not ported here.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SentinelPack {
    pub id: u64,
    pub price: u64,
    pub count: u64,
    pub gold: bool,
    pub special_chance: f64,
}

impl SentinelPack {
    /// The v1 sentinel: empty, free, non-openable. `_pack_body` additionally forces
    /// `state="active"` and `unopened=false` (owned) — documented here, applied by
    /// the shaping layer, not this module.
    pub fn v1() -> Self {
        SentinelPack {
            id: SENTINEL_PACK_ID,
            price: 0,
            count: 0,
            gold: true,
            special_chance: 0.0,
        }
    }
}

#[derive(Debug, Clone)]
struct Session {
    ip: Option<String>,
    persona: i64,
    resolver: Option<u32>,
    mode: Option<StoreMode>,
    last_seen: f64,
}

#[derive(Debug, Clone)]
struct Pending {
    resolver: u32,
    ts: f64,
}

/// The per-session capability/store-mode authority. Not `Sync` itself; the host
/// wraps it in a `Mutex` exactly as the Python oracle guards its tables with a lock.
#[derive(Debug, Default)]
pub struct SessionStore {
    sessions: HashMap<String, Session>,
    pending: HashMap<(Option<String>, i64), Pending>,
}

impl SessionStore {
    /// A fresh, empty store.
    pub fn new() -> Self {
        Self::default()
    }

    /// Reap idle sessions (> [`SESSION_TTL_SECS`]) and expired pendings
    /// (> [`PENDING_TTL_SECS`]). Called at the start of every mutating operation,
    /// mirroring the oracle's lazy reap.
    fn reap(&mut self, now: f64) {
        self.sessions
            .retain(|_, r| now - r.last_seen <= SESSION_TTL_SECS);
        self.pending.retain(|_, p| now - p.ts <= PENDING_TTL_SECS);
    }

    /// Single-use: remove and return a fresh pending resolver for `(ip, persona)`.
    fn take_pending(&mut self, ip: &Option<String>, persona: i64, now: f64) -> Option<u32> {
        let key = (ip.clone(), persona);
        if let Some(p) = self.pending.get(&key) {
            if now - p.ts <= PENDING_TTL_SECS {
                let resolver = p.resolver;
                self.pending.remove(&key);
                return Some(resolver);
            }
        }
        None
    }

    /// `/ut/auth`: open a per-login session and bind any pending launcher capability
    /// for `(ip, persona)` that arrived before login. An empty `sid` is a no-op.
    pub fn open_session(&mut self, sid: &str, ip: Option<String>, persona: i64, now: f64) {
        if sid.is_empty() {
            return;
        }
        self.reap(now);
        let resolver = self.take_pending(&ip, persona, now);
        self.sessions.insert(
            sid.to_string(),
            Session {
                ip,
                persona,
                resolver,
                mode: None,
                last_seen: now,
            },
        );
    }

    /// `/openfut/account/sync` hygiene: drop any stale pending for this machine so a
    /// new launch's unverified session cannot inherit a leftover capability.
    pub fn clear_pending(&mut self, ip: &str, now: f64) {
        self.reap(now);
        let ip_key = Some(ip.to_string());
        self.pending.retain(|(k_ip, _), _| *k_ip != ip_key);
    }

    /// Launcher capability registration. Never authorizes more than one session:
    /// binds iff exactly one live, unfrozen, unbound session for `(ip, persona)`
    /// exists; otherwise stages a single-use pending (no session yet) or fails
    /// closed as ignored-late (a session exists but is frozen/ambiguous).
    pub fn register_capability(
        &mut self,
        ip: Option<String>,
        persona: i64,
        version: u32,
        now: f64,
    ) -> RegisterOutcome {
        self.reap(now);
        let mut any_for_key = false;
        let mut candidate: Option<String> = None;
        let mut candidate_count = 0usize;
        for (sid, r) in &self.sessions {
            if r.ip == ip && r.persona == persona {
                any_for_key = true;
                if r.mode.is_none() && r.resolver.is_none() {
                    candidate = Some(sid.clone());
                    candidate_count += 1;
                }
            }
        }
        if candidate_count == 1 {
            let sid = candidate.expect("exactly one candidate");
            self.sessions
                .get_mut(&sid)
                .expect("candidate session present")
                .resolver = Some(version);
            return RegisterOutcome::Bound;
        }
        if any_for_key {
            return RegisterOutcome::IgnoredLate;
        }
        self.pending.insert(
            (ip, persona),
            Pending {
                resolver: version,
                ts: now,
            },
        );
        RegisterOutcome::Pending
    }

    /// True if `sid` is a live session. (The legacy constant SID is accepted only by
    /// the retired security-question gate in Python — never used to grant clean
    /// mode — and is intentionally not modelled here.)
    pub fn session_known(&self, sid: &str) -> bool {
        self.sessions.contains_key(sid)
    }

    /// Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`.
    /// Freeze point = the first `/store/purchasegroup` of the session. Fail-closed:
    /// an unknown session, or a `sid` presented from a different IP than it was
    /// opened on, resolves to [`StoreMode::Sentinel`] (and a mismatch does NOT freeze
    /// the real session, so a later correct-IP request can still freeze it).
    pub fn empty_mypacks_mode(&mut self, sid: &str, ip: Option<&str>, now: f64) -> StoreMode {
        self.reap(now);

        // Resolve/take pending without holding a mutable borrow across the call.
        let (session_ip, persona, already_frozen, resolver) = match self.sessions.get(sid) {
            None => return StoreMode::Sentinel,
            Some(r) => (r.ip.clone(), r.persona, r.mode, r.resolver),
        };

        if let Some(r) = self.sessions.get_mut(sid) {
            r.last_seen = now;
        }

        // Fail-closed sid/ip sanity check (does not freeze).
        if let (Some(sess_ip), Some(req_ip)) = (session_ip.as_deref(), ip) {
            if sess_ip != req_ip {
                return StoreMode::Sentinel;
            }
        }

        if let Some(mode) = already_frozen {
            return mode; // immutable per SID
        }

        // First store request for this session: consume a still-pending capability
        // if the session was opened before the launcher registered, then freeze.
        let resolver = match resolver {
            Some(v) => Some(v),
            None => self.take_pending(&session_ip, persona, now),
        };
        let mode = if resolver == Some(EMPTY_MYPACKS_RESOLVER_VERSION) {
            StoreMode::CleanV1
        } else {
            StoreMode::Sentinel
        };
        if let Some(r) = self.sessions.get_mut(sid) {
            r.resolver = resolver;
            r.mode = Some(mode);
        }
        mode
    }

    /// Store-catalogue decision for a `/store/purchasegroup` request. Mirrors the
    /// oracle's `if not owned_ids:` gate: with owned unopened packs the real
    /// `mypacks` group is served and no freeze occurs (`None`); only an *empty*
    /// My Packs freezes and returns the topology mode.
    pub fn store_empty_mypacks(
        &mut self,
        sid: &str,
        ip: Option<&str>,
        has_unopened_packs: bool,
        now: f64,
    ) -> Option<StoreMode> {
        if has_unopened_packs {
            return None;
        }
        Some(self.empty_mypacks_mode(sid, ip, now))
    }
}

#[cfg(test)]
mod tests {
    //! Ports the Python capability-negotiation matrix A–R
    //! (`fifa17-recon/tools/test_capability_negotiation.py`). Python is the oracle;
    //! these assertions must stay in lockstep with it.
    use super::*;

    const IP1: &str = "10.10.0.105";
    const IP2: &str = "10.10.0.106";
    const PERSONA: i64 = 111001;

    fn ip(s: &str) -> Option<String> {
        Some(s.to_string())
    }

    // A: no-capability, zero packs -> sentinel
    #[test]
    fn a_no_capability_zero_packs_sentinel() {
        let mut s = SessionStore::new();
        s.open_session("sidA", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.store_empty_mypacks("sidA", Some(IP1), false, 1.0),
            Some(StoreMode::Sentinel)
        );
    }

    // B: verified v1, zero packs -> clean
    #[test]
    fn b_verified_v1_zero_packs_clean() {
        let mut s = SessionStore::new();
        s.open_session("sidB", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        assert_eq!(
            s.store_empty_mypacks("sidB", Some(IP1), false, 2.0),
            Some(StoreMode::CleanV1)
        );
    }

    // C: real unopened pack + no capability -> genuine packs (no freeze/sentinel)
    #[test]
    fn c_real_pack_no_capability_genuine() {
        let mut s = SessionStore::new();
        s.open_session("sidC", ip(IP1), PERSONA, 0.0);
        assert_eq!(s.store_empty_mypacks("sidC", Some(IP1), true, 1.0), None);
    }

    // D: real unopened pack + capability -> genuine packs (no freeze)
    #[test]
    fn d_real_pack_with_capability_genuine() {
        let mut s = SessionStore::new();
        s.open_session("sidD", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        assert_eq!(s.store_empty_mypacks("sidD", Some(IP1), true, 2.0), None);
    }

    // E: unsupported version / capability -> endpoint rejects AND mode sentinel
    #[test]
    fn e_unsupported_capability_rejected_and_sentinel() {
        assert_eq!(validate_capability(CAPABILITY_NAME, 1), Ok(()));
        assert_eq!(
            validate_capability(CAPABILITY_NAME, 2),
            Err(CapabilityError::Unsupported)
        );
        assert_eq!(
            validate_capability("something_else", 1),
            Err(CapabilityError::Unsupported)
        );
        let mut s = SessionStore::new();
        s.open_session("sidE", ip(IP1), PERSONA, 0.0);
        // A rejected registration records nothing, so the session stays sentinel.
        assert_eq!(
            s.empty_mypacks_mode("sidE", Some(IP1), 1.0),
            StoreMode::Sentinel
        );
    }

    // F: late capability after sentinel freeze -> stays sentinel
    #[test]
    fn f_late_capability_after_sentinel_freeze_stays() {
        let mut s = SessionStore::new();
        s.open_session("sidF", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.empty_mypacks_mode("sidF", Some(IP1), 1.0),
            StoreMode::Sentinel
        );
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 2.0),
            RegisterOutcome::IgnoredLate
        );
        assert_eq!(
            s.empty_mypacks_mode("sidF", Some(IP1), 3.0),
            StoreMode::Sentinel
        );
    }

    // G: capability "disappears" after clean freeze -> stays clean (immutable)
    #[test]
    fn g_clean_freeze_immutable() {
        let mut s = SessionStore::new();
        s.open_session("sidG", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        assert_eq!(
            s.empty_mypacks_mode("sidG", Some(IP1), 2.0),
            StoreMode::CleanV1
        );
        assert_eq!(
            s.empty_mypacks_mode("sidG", Some(IP1), 3.0),
            StoreMode::CleanV1
        );
    }

    // H: two IPs (A verified, B none) -> A clean, B sentinel (no global leak)
    #[test]
    fn h_two_ips_isolated() {
        let mut s = SessionStore::new();
        s.open_session("sidHa", ip(IP1), PERSONA, 0.0);
        s.open_session("sidHb", ip(IP2), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        assert_eq!(
            s.empty_mypacks_mode("sidHa", Some(IP1), 2.0),
            StoreMode::CleanV1
        );
        assert_eq!(
            s.empty_mypacks_mode("sidHb", Some(IP2), 2.0),
            StoreMode::Sentinel
        );
    }

    // I: new session after reset -> fresh unpatched -> sentinel
    #[test]
    fn i_fresh_session_sentinel() {
        let mut s = SessionStore::new();
        s.open_session("sidI", ip(IP1), PERSONA, 10.0);
        assert_eq!(
            s.empty_mypacks_mode("sidI", Some(IP1), 11.0),
            StoreMode::Sentinel
        );
    }

    // J: autopatch mismatch => never registers -> sentinel
    #[test]
    fn j_no_registration_sentinel() {
        let mut s = SessionStore::new();
        s.open_session("sidJ", ip(IP1), PERSONA, 0.0);
        // (no register_capability call at all)
        assert_eq!(
            s.empty_mypacks_mode("sidJ", Some(IP1), 1.0),
            StoreMode::Sentinel
        );
    }

    // K: SAME IP, two sessions (A patched, B not) -> A clean, B sentinel
    #[test]
    fn k_same_ip_two_sessions_isolated() {
        let mut s = SessionStore::new();
        s.open_session("sidKa", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        s.open_session("sidKb", ip(IP1), PERSONA, 2.0);
        assert_eq!(
            s.empty_mypacks_mode("sidKa", Some(IP1), 3.0),
            StoreMode::CleanV1
        );
        assert_eq!(
            s.empty_mypacks_mode("sidKb", Some(IP1), 3.0),
            StoreMode::Sentinel
        );
    }

    // L: SAME IP+persona relaunch (old ok, new not) -> new session sentinel
    #[test]
    fn l_same_ip_persona_relaunch() {
        let mut s = SessionStore::new();
        s.open_session("sidLold", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        assert_eq!(
            s.empty_mypacks_mode("sidLold", Some(IP1), 2.0),
            StoreMode::CleanV1
        );
        s.open_session("sidLnew", ip(IP1), PERSONA, 3.0);
        assert_eq!(
            s.empty_mypacks_mode("sidLnew", Some(IP1), 4.0),
            StoreMode::Sentinel
        );
        assert_eq!(
            s.empty_mypacks_mode("sidLold", Some(IP1), 5.0),
            StoreMode::CleanV1
        );
    }

    // M: SAME IP, failed-patch second session -> first clean, second sentinel
    #[test]
    fn m_same_ip_failed_patch_second() {
        let mut s = SessionStore::new();
        s.open_session("sidMa", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        s.open_session("sidMb", ip(IP1), PERSONA, 2.0); // failed patch: never registers
        assert_eq!(
            s.empty_mypacks_mode("sidMa", Some(IP1), 3.0),
            StoreMode::CleanV1
        );
        assert_eq!(
            s.empty_mypacks_mode("sidMb", Some(IP1), 3.0),
            StoreMode::Sentinel
        );
    }

    // N: late registration when sessions are frozen -> does not modify active,
    //    and does not stage a pending that a later session could inherit.
    #[test]
    fn n_late_registration_no_effect() {
        let mut s = SessionStore::new();
        s.open_session("sidN", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.empty_mypacks_mode("sidN", Some(IP1), 1.0),
            StoreMode::Sentinel
        );
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 2.0),
            RegisterOutcome::IgnoredLate
        );
        assert_eq!(
            s.empty_mypacks_mode("sidN", Some(IP1), 3.0),
            StoreMode::Sentinel
        );
        // No pending was staged, so a brand-new session cannot inherit it.
        s.open_session("sidN2", ip(IP1), PERSONA, 4.0);
        assert_eq!(
            s.empty_mypacks_mode("sidN2", Some(IP1), 5.0),
            StoreMode::Sentinel
        );
    }

    // O: session cleanup / TTL expiry -> capability gone, sentinel
    #[test]
    fn o_ttl_expiry() {
        // Pending expiry: registered before login, but login arrives too late.
        let mut s = SessionStore::new();
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 0.0),
            RegisterOutcome::Pending
        );
        s.open_session("sidO", ip(IP1), PERSONA, PENDING_TTL_SECS + 1.0);
        assert_eq!(
            s.empty_mypacks_mode("sidO", Some(IP1), PENDING_TTL_SECS + 2.0),
            StoreMode::Sentinel
        );
        // Session expiry: a known session reaped after idle TTL becomes unknown.
        let mut s2 = SessionStore::new();
        s2.open_session("sidO2", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s2.empty_mypacks_mode("sidO2", Some(IP1), SESSION_TTL_SECS + 1.0),
            StoreMode::Sentinel
        );
        assert!(!s2.session_known("sidO2"));
    }

    // P: duplicate registration for a session -> idempotent; no post-freeze change
    #[test]
    fn p_duplicate_registration_idempotent() {
        let mut s = SessionStore::new();
        s.open_session("sidP", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        // Second registration: the session is now bound (resolver set), so it is no
        // longer an unbound candidate -> ignored-late, and the outcome is unchanged.
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 2.0),
            RegisterOutcome::IgnoredLate
        );
        assert_eq!(
            s.empty_mypacks_mode("sidP", Some(IP1), 3.0),
            StoreMode::CleanV1
        );
    }

    // Q: register-before-login (pending consumed) -> clean; single-use
    #[test]
    fn q_register_before_login_pending_consumed() {
        let mut s = SessionStore::new();
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 0.0),
            RegisterOutcome::Pending
        );
        s.open_session("sidQ", ip(IP1), PERSONA, 1.0);
        assert_eq!(
            s.empty_mypacks_mode("sidQ", Some(IP1), 2.0),
            StoreMode::CleanV1
        );
        // Single-use: a second login for the same (ip,persona) gets no capability.
        s.open_session("sidQ2", ip(IP1), PERSONA, 3.0);
        assert_eq!(
            s.empty_mypacks_mode("sidQ2", Some(IP1), 4.0),
            StoreMode::Sentinel
        );
    }

    // R: topology freeze immutable per SID -> no flip either way; new SID fresh
    #[test]
    fn r_topology_freeze_immutable_per_sid() {
        let mut s = SessionStore::new();
        s.open_session("sidR", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        assert_eq!(
            s.empty_mypacks_mode("sidR", Some(IP1), 2.0),
            StoreMode::CleanV1
        );
        assert_eq!(
            s.empty_mypacks_mode("sidR", Some(IP1), 3.0),
            StoreMode::CleanV1
        );
        // A brand-new SID from the same client is a fresh, unverified session.
        s.open_session("sidRnew", ip(IP1), PERSONA, 4.0);
        assert_eq!(
            s.empty_mypacks_mode("sidRnew", Some(IP1), 5.0),
            StoreMode::Sentinel
        );
    }

    // Extra: sid/ip mismatch is fail-closed and does NOT freeze the real session.
    #[test]
    fn sid_ip_mismatch_fails_closed_without_freezing() {
        let mut s = SessionStore::new();
        s.open_session("sidX", ip(IP1), PERSONA, 0.0);
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 1.0),
            RegisterOutcome::Bound
        );
        // Presented from the wrong IP: sentinel, but the session is not frozen.
        assert_eq!(
            s.empty_mypacks_mode("sidX", Some(IP2), 2.0),
            StoreMode::Sentinel
        );
        // The genuine client (correct IP) still freezes clean.
        assert_eq!(
            s.empty_mypacks_mode("sidX", Some(IP1), 3.0),
            StoreMode::CleanV1
        );
    }

    // Extra: account_sync clears a stale pending for the machine.
    #[test]
    fn clear_pending_drops_stale_hand_off() {
        let mut s = SessionStore::new();
        assert_eq!(
            s.register_capability(ip(IP1), PERSONA, 1, 0.0),
            RegisterOutcome::Pending
        );
        s.clear_pending(IP1, 1.0);
        s.open_session("sidC1", ip(IP1), PERSONA, 2.0);
        assert_eq!(
            s.empty_mypacks_mode("sidC1", Some(IP1), 3.0),
            StoreMode::Sentinel
        );
    }

    // Extra: format_sid shape matches the legacy constant length.
    #[test]
    fn format_sid_shape() {
        assert_eq!(
            format_sid(0x42C15A6F78DC6E74),
            "OPENFUT-SID-42C15A6F78DC6E74"
        );
        assert_eq!(format_sid(0).len(), "OPENFUT-SID-".len() + 16);
    }
}
