//! Session restore from persistent storage and WAL replay.

use std::{
    collections::HashMap,
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    },
};

use super::TidalDb;
use crate::{
    schema::{EntityId, Timestamp},
    session::{self as session_mod, AgentId, SessionId, SessionState},
    storage::{Tag, encode_key, parse_key},
    wal::format::session::SessionSeqNo,
};

/// Reconstruct a monotonic `Instant` that reflects a session's **true** age from
/// the durable wall-clock anchor `started_at_ns`.
///
/// The monotonic clock is not durable: a restored session has no way to recover
/// the original `Instant`. Seeding it with a fresh `Instant::now()` would reset
/// the session's apparent age to ~zero, silently voiding every monotonic-clock
/// age computation (the TTL sweeper and the policy duration check both measure
/// age, and `build_snapshot` reports `started_at.elapsed()`). Back-dating the
/// `Instant` by the elapsed wall-clock time makes `started_at.elapsed()` and
/// `now.duration_since(started_at)` reflect the real age. `started_at_ns` remains
/// the authoritative source; the back-dated `Instant` is defense-in-depth so any
/// path still reading the monotonic clock stays correct.
///
/// Falls back to `Instant::now()` only if the subtraction would underflow (the
/// recorded start is implausibly far in the past relative to process start).
fn back_dated_instant(started_at_ns: u64, now_ns: u64) -> std::time::Instant {
    let elapsed_ns = now_ns.saturating_sub(started_at_ns);
    std::time::Instant::now()
        .checked_sub(std::time::Duration::from_nanos(elapsed_ns))
        .unwrap_or_else(std::time::Instant::now)
}

impl TidalDb {
    /// Scan persistent storage for previously archived session snapshots and
    /// reconcile durable session state with what was restored from the WAL.
    ///
    /// Called once at startup from `TidalDbBuilder::open()`, **after**
    /// `restore_session_wal_events` has run (in `from_parts`), so `self.sessions`
    /// already holds the sessions that had a WAL `Start` without a `Close`. Walks
    /// every key in the items storage engine, filters for `Tag::Session` keys,
    /// and:
    /// - `b"snapshot"` suffix -> deserializes into `closed_sessions`
    /// - `b"start"` suffix   -> a session that was active at last shutdown
    ///
    /// This routine re-derives two durable invariants that the in-memory restore
    /// cannot infer on its own:
    ///
    /// 1. **Id-allocator high-water mark.** `next_session_id` is seeded to 1 on
    ///    every open; if it is not advanced past *every* durable session id, a
    ///    clean restart re-issues id 1 and the next `close_session` overwrites the
    ///    oldest archived snapshot — silent, permanent data loss. We track the max
    ///    id over every snapshot AND every start record and CAS-advance the
    ///    allocator past it (saturating, mirroring `restore_session_wal_events`).
    ///    Because this runs after the WAL path and the CAS only ever moves the
    ///    allocator forward, the final value is the max over both sources
    ///    regardless of call order.
    ///
    /// 2. **Orphaned start records.** A `b"start"` record with no live session
    ///    and no archived snapshot describes a session that was active at crash
    ///    time but whose WAL `Start` is gone (the append-only session journal is
    ///    never compacted, and recovery stops at the first torn record, so later
    ///    starts can be lost). Left alone it re-warns on every open forever and
    ///    leaks a dead key. We seal it once: build a frozen, zero-signal closed
    ///    snapshot (true age from `started_at_ns`) and atomically `put` the
    ///    snapshot + `delete` the start record, converting the unrecoverable
    ///    session into an archived record.
    ///
    /// In ephemeral mode the in-memory backend starts empty each time so this
    /// scan always returns nothing -- which is correct.
    pub(crate) fn restore_sessions(&self) {
        let Some(storage) = self.storage.as_ref() else {
            return;
        };
        let engine = storage.items_engine();
        let mut restored = 0usize;
        let mut orphaned = 0usize;
        let mut sealed = 0usize;
        // High-water mark over every durable session id (snapshots + starts).
        let mut max_id: Option<u64> = None;
        // Orphaned start records to seal after the scan, as
        // (session_id, user_id, started_at_ns, metadata). Deferred so we never
        // mutate storage mid-iteration.
        let mut orphans: Vec<(u64, u64, u64, HashMap<String, String>)> = Vec::new();

        for item in engine.scan_prefix(&[]) {
            let (key, value) = match item {
                Ok(kv) => kv,
                Err(e) => {
                    tracing::warn!(error = %e, "storage scan error during session restore");
                    continue;
                }
            };
            let Some((_entity_id, tag, suffix)) = parse_key(&key) else {
                continue;
            };
            if tag != Tag::Session {
                continue;
            }
            match suffix {
                b"snapshot" => {
                    if let Some(snapshot) = session_mod::deserialize_snapshot(&value) {
                        max_id = Some(
                            max_id.map_or(snapshot.id.as_u64(), |m| m.max(snapshot.id.as_u64())),
                        );
                        self.closed_sessions.insert(snapshot.id, snapshot);
                        restored += 1;
                    } else {
                        tracing::warn!("corrupt session snapshot key {:?}; skipping", key);
                    }
                }
                b"start" => {
                    if let Some((session_id, user_id, started_at_ns, metadata)) =
                        session_mod::deserialize_start_record(&value)
                    {
                        let id = session_id.as_u64();
                        max_id = Some(max_id.map_or(id, |m| m.max(id)));
                        orphaned += 1;
                        // A start record whose session was rehydrated as active
                        // from the WAL will be cleaned up by its eventual close;
                        // leave it. Only seal records with no live session and no
                        // durable snapshot — those are truly unrecoverable.
                        if self.sessions.contains_key(&session_id)
                            || self.closed_sessions.contains_key(&session_id)
                        {
                            tracing::warn!(
                                session_id = %session_id,
                                "session was active at last shutdown; in-memory signal state is lost"
                            );
                        } else {
                            orphans.push((id, user_id, started_at_ns, metadata));
                        }
                    }
                }
                _ => {}
            }
        }

        // Advance the id allocator past the highest durable session id so a
        // reopen never re-issues a live/archived id and overwrites its snapshot.
        if let Some(id) = max_id {
            self.advance_next_session_id(id);
        }

        // Seal each truly-orphaned start record into a frozen closed snapshot.
        for (session_id, user_id, started_at_ns, metadata) in orphans {
            if self.seal_orphaned_start(session_id, user_id, started_at_ns, metadata) {
                sealed += 1;
            }
        }

        if restored > 0 || orphaned > 0 || sealed > 0 {
            tracing::info!(restored, orphaned, sealed, "session restore complete");
        }
    }

    /// Advance `next_session_id` past `id` with a saturating CAS loop.
    ///
    /// Idempotent and monotonic: the allocator only ever moves forward, so it
    /// converges to the max over every restore source regardless of call order.
    /// A restored id of `u64::MAX` saturates the allocator at the ceiling rather
    /// than wrapping back to 0 (which would re-issue a live id).
    fn advance_next_session_id(&self, id: u64) {
        loop {
            let current = self.next_session_id.load(Ordering::Acquire);
            if id < current {
                break;
            }
            if self
                .next_session_id
                .compare_exchange(
                    current,
                    id.saturating_add(1),
                    Ordering::Release,
                    Ordering::Relaxed,
                )
                .is_ok()
            {
                break;
            }
        }
    }

    /// Seal an orphaned `b"start"` record into a frozen, zero-signal closed
    /// snapshot, atomically replacing the start key with a snapshot key.
    ///
    /// Returns `true` if the record was sealed. The snapshot carries the true
    /// wall-clock age (from the durable `started_at_ns`) and zero in-memory
    /// signals — explicitly marking that the session's live signal state was
    /// lost. This stops the recurring "orphaned" warning, reclaims the key, and
    /// preserves the session as an archived record retrievable via
    /// `session_snapshot`.
    fn seal_orphaned_start(
        &self,
        session_id: u64,
        user_id: u64,
        started_at_ns: u64,
        metadata: HashMap<String, String>,
    ) -> bool {
        let Some(storage) = self.storage.as_ref() else {
            return false;
        };

        let snapshot_key = encode_key(EntityId::new(session_id), Tag::Session, b"snapshot");
        let start_key = encode_key(EntityId::new(session_id), Tag::Session, b"start");
        let sid = SessionId::from_raw(session_id);

        // Defense-in-depth against scan order and torn closes: never overwrite an
        // existing durable snapshot with a zero-signal seal. If a snapshot is
        // already present (a torn close left snapshot+start both durable), the
        // snapshot is authoritative — just delete the stale start record and warm
        // the archive from the real snapshot.
        if let Ok(Some(bytes)) = storage.items_engine().get(&snapshot_key) {
            if let Some(snapshot) = session_mod::deserialize_snapshot(&bytes) {
                self.closed_sessions.insert(sid, snapshot);
            }
            let mut batch = crate::storage::WriteBatch::new();
            batch.delete(start_key);
            if let Err(e) = storage.items_engine().write_batch(batch) {
                tracing::warn!(
                    error = %e,
                    session_id,
                    "failed to delete stale start record shadowed by a durable snapshot"
                );
                return false;
            }
            tracing::info!(
                session_id,
                "deleted stale start record shadowed by an existing durable snapshot"
            );
            return true;
        }

        // Build a zero-signal SessionState carrying the durable identity, then
        // freeze it. `agent_id` is not in the start record, so use the fallback
        // "restored" marker (always a valid AgentId). The frozen `duration_ms` is
        // the TRUE wall-clock age from `started_at_ns`; `build_frozen_snapshot`
        // takes the duration explicitly, so the monotonic `started_at` here only
        // needs to be self-consistent — back-date it for the same reason the live
        // restore does, so any future reader of the monotonic clock stays correct.
        let now_ns = Timestamp::now().as_nanos();
        let duration_ms = now_ns.saturating_sub(started_at_ns) / 1_000_000;
        let started_at = back_dated_instant(started_at_ns, now_ns);
        let agent_id = AgentId::new("restored").expect("'restored' is a valid AgentId");

        let state = SessionState::new(
            SessionId::from_raw(session_id),
            user_id,
            agent_id,
            String::new(),
            started_at,
            started_at_ns,
            metadata,
            Arc::new(AtomicBool::new(true)),
        );
        let snapshot = session_mod::build_frozen_snapshot(&state, duration_ms);
        let snapshot_bytes = session_mod::serialize_snapshot(&snapshot);

        let mut batch = crate::storage::WriteBatch::new();
        batch.put(snapshot_key, snapshot_bytes);
        batch.delete(start_key);
        if let Err(e) = storage.items_engine().write_batch(batch) {
            tracing::warn!(
                error = %e,
                session_id,
                "failed to seal orphaned session start record"
            );
            return false;
        }

        // Warm the in-memory archive so session_snapshot resolves without a
        // storage round-trip and active_sessions never lists it.
        self.closed_sessions.insert(sid, snapshot);
        self.enforce_closed_session_cap();
        tracing::info!(
            session_id,
            "sealed orphaned session start record into a frozen closed snapshot"
        );
        true
    }

    /// Replay session journal events to restore active sessions from a crash.
    ///
    /// Scans the event list for Start/Signal/Close triples. Sessions that have
    /// a `Start` but no matching `Close` are restored as active sessions with
    /// their signals replayed into `SessionSignalState`.
    ///
    /// Called from `from_parts()` during startup. The storage-backed
    /// `restore_sessions()` is additive and handles archived snapshots; this
    /// method handles in-flight sessions that were active at crash time.
    ///
    /// # Durable snapshot is authoritative over the journal
    ///
    /// `close_session_internal` commits the durable `b"snapshot"` (and deletes
    /// the `b"start"`) **before** appending the journal `Close`, and tolerates a
    /// failed `Close` append with only a warning. The append-only session journal
    /// is never compacted, so the original `Start` survives forever. A crash
    /// between the snapshot commit and the `Close` append therefore leaves a
    /// durable snapshot AND a `Start`-without-`Close` in the journal. Restoring
    /// such a session as ACTIVE would land the same id in both `self.sessions`
    /// and `closed_sessions` — a phantom session that accepts new signals against
    /// a session the durable record (and the operator) consider closed. To
    /// prevent this we probe storage for the session's `b"snapshot"` key before
    /// building the active state and skip any session that has one: the durable
    /// snapshot is the record of truth.
    #[allow(clippy::too_many_lines)]
    pub(super) fn restore_session_wal_events(
        &self,
        events: &[crate::wal::format::SessionWalEvent],
    ) {
        let (open_sessions, session_signals) = Self::partition_session_events(events);

        if open_sessions.is_empty() {
            return;
        }

        let Some(schema) = self.schema_def.as_ref() else {
            tracing::warn!("no schema available; cannot restore sessions from WAL");
            return;
        };

        for (session_id, (user_id, started_at_ns, agent_id_str, policy_name)) in open_sessions {
            // The durable snapshot is authoritative: if this session was already
            // archived (snapshot committed but its journal Close was lost/torn),
            // it is CLOSED. Restoring it as ACTIVE would place the same id in both
            // self.sessions and closed_sessions (phantom state). Skip it — but
            // still advance the allocator past its id below so a reuse cannot
            // overwrite the snapshot. A point lookup on a cold path.
            let has_snapshot = self.storage.as_ref().is_some_and(|storage| {
                let key = encode_key(EntityId::new(session_id), Tag::Session, b"snapshot");
                storage.items_engine().get(&key).ok().flatten().is_some()
            });
            if has_snapshot {
                tracing::info!(
                    session_id,
                    "session has a durable closed snapshot; not restoring as active \
                     (Close journal record was lost between snapshot commit and append)"
                );
                self.advance_next_session_id(session_id);
                continue;
            }

            if schema.session_policy(&policy_name).is_none() {
                tracing::warn!(
                    session_id,
                    policy_name,
                    "restored session has unknown policy, skipping"
                );
                continue;
            }

            let agent_id = AgentId::new(&agent_id_str).unwrap_or_else(|_| {
                tracing::warn!(
                    session_id,
                    agent_id = agent_id_str,
                    "invalid agent_id in WAL, using fallback 'restored'"
                );
                // Fallback: AgentId::new("restored") is always valid (lowercase ASCII).
                AgentId::new("restored").expect("'restored' is a valid AgentId")
            });

            let closed = Arc::new(AtomicBool::new(false));

            // Rehydrate metadata from the persisted start record in storage.
            // The WAL Start event does not carry metadata; the storage start record does.
            let metadata = self
                .storage
                .as_ref()
                .and_then(|storage| {
                    let key = encode_key(EntityId::new(session_id), Tag::Session, b"start");
                    storage.items_engine().get(&key).ok().flatten()
                })
                .and_then(|bytes| session_mod::deserialize_start_record(&bytes))
                .map(|(_, _, _, meta)| meta)
                .unwrap_or_default();

            // The monotonic Instant is not durable; back-date it from the durable
            // `started_at_ns` so `started_at.elapsed()`, the TTL sweeper, and the
            // policy duration check all see the session's TRUE age rather than
            // time-since-restore. Seeding with a raw `Instant::now()` would reset
            // a restored session's age to ~zero and silently void TTL enforcement.
            let started_at = back_dated_instant(started_at_ns, Timestamp::now().as_nanos());

            let state = Arc::new(SessionState::new(
                SessionId::from_raw(session_id),
                user_id,
                agent_id,
                policy_name.clone(),
                started_at,
                started_at_ns,
                metadata,
                closed,
            ));

            // Replay signals and annotations into the restored session state.
            // Advance the seqno HWM so incoming replication events are correctly deduped.
            //
            // CRITICAL (§8 "WAL replay produces identical state"): replay events
            // in ascending ts_ns order and seed each per-signal-type state with
            // that type's *earliest* event timestamp, not the restore-time wall
            // clock. The live path seeds `SessionSignalState::new(ts_ns, ..)`
            // with the first signal's own timestamp (db/sessions.rs), so its
            // BucketedCounter rotations fire as real time advances. If we seeded
            // with `now_ns` and fed past timestamps, `maybe_rotate` would early-
            // return on every event and collapse all replayed signals into one
            // current-minute bucket, diverging the restored window counts. By
            // sorting ascending, the first occurrence of each signal_name in the
            // iteration carries that name's earliest ts, so the `or_insert_with`
            // seed is exactly the live seed and rotations advance identically.
            let mut max_seqno: Option<u64> = None;
            if let Some(signals) = session_signals.get(&session_id) {
                let mut ordered: Vec<_> = signals.iter().collect();
                ordered.sort_by_key(|(_, _, ts_ns, _, _, _)| *ts_ns);
                for (entity_id, weight, ts_ns, signal_name, annotation, seqno_raw) in ordered {
                    let lambda = schema
                        .signal(signal_name)
                        .and_then(|def| def.decay().lambda())
                        .unwrap_or(session_mod::DEFAULT_SESSION_LAMBDA);

                    // Seed with this event's own ts_ns: because events are sorted
                    // ascending, the first signal of each name carries its
                    // earliest timestamp, matching the live `new(ts_ns, lambda)`.
                    let ss = state
                        .signals
                        .entry(signal_name.clone())
                        .or_insert_with(|| session_mod::SessionSignalState::new(*ts_ns, lambda));
                    ss.on_signal(*weight, *ts_ns);
                    drop(ss);

                    // Route through the capped insert so WAL replay honors the
                    // MAX_SIGNALED_ENTITIES bound exactly as the live path does;
                    // a direct .insert would bypass the cap and let a replayed
                    // session grow signaled_entities without limit.
                    state.track_signaled_entity(*entity_id);
                    state.signals_written.fetch_add(1, Ordering::Relaxed);

                    // Track the highest seqno seen so we can advance the HWM after replay.
                    if let Some(s) = *seqno_raw {
                        max_seqno = Some(max_seqno.map_or(s, |m| m.max(s)));
                    }

                    // Replay annotation if present.
                    if let Some(ann) = annotation
                        && let Ok(mut anns) = state.annotations.lock()
                        && anns.len() < session_mod::MAX_ANNOTATIONS
                    {
                        anns.push((*ts_ns, ann.clone()));
                    }
                }
            }

            // Advance the seqno tracker HWM so the bridge deduplicates any
            // replication events that repeat signals already replayed from the WAL.
            if let Some(hwm) = max_seqno {
                self.session_bridge
                    .seqno_tracker()
                    .set_hwm(session_id, SessionSeqNo(hwm));
            }

            let sid = SessionId::from_raw(session_id);
            self.sessions.insert(sid, state);

            // Advance next_session_id past any restored session IDs (saturating,
            // so a restored id of u64::MAX pins the allocator rather than wrapping
            // back to 0 and re-issuing a live id).
            self.advance_next_session_id(session_id);

            tracing::info!(session_id, user_id, "restored active session from WAL");
        }
    }

    /// Partition session WAL events into open sessions and their signals.
    ///
    /// Returns `(open_sessions, session_signals)` where `open_sessions` maps
    /// `session_id` to `(user_id, started_at_ns, agent_id, policy_name)` for
    /// sessions that have a Start but no Close.
    ///
    /// Each signal tuple is `(entity_id, weight, ts_ns, signal_name, annotation, seqno)`.
    /// `seqno` is `Some(n)` for events written after the M8p4 seqno mechanism was added,
    /// `None` for legacy events.
    #[allow(clippy::type_complexity)]
    pub(super) fn partition_session_events(
        events: &[crate::wal::format::SessionWalEvent],
    ) -> (
        HashMap<u64, (u64, u64, String, String)>,
        HashMap<u64, Vec<(u64, f64, u64, String, Option<String>, Option<u64>)>>,
    ) {
        use crate::wal::format::SessionWalEvent;

        let mut open_sessions: HashMap<u64, (u64, u64, String, String)> = HashMap::new();
        let mut session_signals: HashMap<
            u64,
            Vec<(u64, f64, u64, String, Option<String>, Option<u64>)>,
        > = HashMap::new();

        for event in events {
            match event {
                SessionWalEvent::Start {
                    session_id,
                    user_id,
                    started_at_ns,
                    agent_id,
                    policy_name,
                } => {
                    open_sessions.insert(
                        *session_id,
                        (
                            *user_id,
                            *started_at_ns,
                            agent_id.clone(),
                            policy_name.clone(),
                        ),
                    );
                }
                SessionWalEvent::Signal {
                    session_id,
                    entity_id,
                    weight,
                    ts_ns,
                    signal_name,
                    annotation,
                    session_seqno,
                    ..
                } => {
                    session_signals.entry(*session_id).or_default().push((
                        *entity_id,
                        *weight,
                        *ts_ns,
                        signal_name.clone(),
                        annotation.clone(),
                        session_seqno.map(|s| s.0),
                    ));
                }
                SessionWalEvent::Close { session_id } => {
                    open_sessions.remove(session_id);
                    session_signals.remove(session_id);
                }
            }
        }

        (open_sessions, session_signals)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::{sync::atomic::Ordering, time::Duration};

    use super::TidalDb;
    use crate::{
        schema::{AgentPolicy, DecaySpec, EntityKind, SchemaBuilder},
        wal::format::SessionWalEvent,
    };

    /// Schema with a single session policy named "agent" so a WAL Start event
    /// referencing that policy survives the restore path's policy check.
    fn schema_with_session_policy() -> crate::schema::Schema {
        let mut b = SchemaBuilder::new();
        let _ = b
            .signal("view", EntityKind::Item, DecaySpec::Permanent)
            .add();
        b.session_policy(
            "agent",
            AgentPolicy {
                allowed_signals: vec!["view".to_string()],
                denied_signals: vec![],
                max_session_duration: Duration::from_secs(3600),
                max_signals_per_session: 10_000,
            },
        );
        b.build().expect("schema with session policy must be valid")
    }

    /// Regression: restoring a session whose id is `u64::MAX` must NOT wrap
    /// `next_session_id` back to 0 (an unguarded `session_id + 1` would
    /// overflow-panic in debug or wrap to 0 in release, re-issuing a live id).
    /// The saturating advance pins the allocator at the ceiling instead.
    #[test]
    fn restore_at_max_session_id_does_not_wrap_allocator() {
        let db = TidalDb::builder()
            .ephemeral()
            .with_schema(schema_with_session_policy())
            .open()
            .unwrap();

        let events = vec![SessionWalEvent::Start {
            session_id: u64::MAX,
            user_id: 7,
            started_at_ns: 0,
            agent_id: "agent".to_string(),
            policy_name: "agent".to_string(),
        }];
        db.restore_session_wal_events(&events);

        // The advance saturated at u64::MAX rather than wrapping to 0.
        assert_eq!(
            db.next_session_id.load(Ordering::Acquire),
            u64::MAX,
            "next_session_id must saturate, not wrap to 0, when restoring id u64::MAX"
        );
        db.close().unwrap();
    }

    /// Build a frozen, zero-signal snapshot for `session_id`/`user_id` so a test
    /// can plant a durable `b"snapshot"` row directly in storage.
    fn frozen_snapshot_bytes(session_id: u64, user_id: u64) -> Vec<u8> {
        use std::sync::{Arc, atomic::AtomicBool};

        use crate::session::{AgentId, SessionId, SessionState};

        let state = SessionState::new(
            SessionId::from_raw(session_id),
            user_id,
            AgentId::new("agent").unwrap(),
            "agent".to_string(),
            std::time::Instant::now(),
            crate::schema::Timestamp::now().as_nanos(),
            std::collections::HashMap::new(),
            Arc::new(AtomicBool::new(true)),
        );
        let snap = crate::session::build_frozen_snapshot(&state, 0);
        crate::session::serialize_snapshot(&snap)
    }

    /// CRITICAL regression: a session with a durable `b"snapshot"` is CLOSED. A
    /// stale `Start`-without-`Close` in the journal (the snapshot committed but
    /// its `Close` append was lost) must NOT restore it as ACTIVE — that would
    /// land the same id in both `self.sessions` and the durable archive (phantom
    /// state). The restore path probes for the snapshot and skips, while still
    /// advancing the allocator past the id.
    #[test]
    fn durable_snapshot_blocks_active_restore() {
        use crate::{
            schema::EntityId,
            session::SessionId,
            storage::{Tag, encode_key},
        };

        let dir = tempfile::tempdir().unwrap();
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema_with_session_policy())
            .open()
            .unwrap();

        // Plant a durable closed snapshot for id 5.
        let snap_key = encode_key(EntityId::new(5), Tag::Session, b"snapshot");
        db.storage
            .as_ref()
            .unwrap()
            .items_engine()
            .put(&snap_key, &frozen_snapshot_bytes(5, 99))
            .unwrap();

        // A surviving Start-without-Close for the SAME id.
        let events = vec![SessionWalEvent::Start {
            session_id: 5,
            user_id: 99,
            started_at_ns: crate::schema::Timestamp::now().as_nanos(),
            agent_id: "agent".to_string(),
            policy_name: "agent".to_string(),
        }];
        db.restore_session_wal_events(&events);

        // The session must NOT be active (durable snapshot is authoritative)...
        assert!(
            !db.sessions.contains_key(&SessionId::from_raw(5)),
            "a session with a durable snapshot must not be restored as active"
        );
        // ...but the allocator must still have advanced past its id so a reuse
        // cannot overwrite the snapshot.
        assert!(
            db.next_session_id.load(Ordering::Acquire) > 5,
            "the allocator must advance past a snapshot-shadowed restore id"
        );
        db.close().unwrap();
    }

    /// WARNING regression: an orphaned `b"start"` record (no live session, no
    /// durable snapshot, and no matching WAL Start) must be sealed exactly once
    /// into a frozen closed snapshot — the start key is deleted, a snapshot key
    /// is written, the session is queryable as archived, and the allocator is
    /// advanced past its id. This stops the permanently-re-firing "orphaned"
    /// warning and reclaims the dead key.
    #[test]
    fn orphaned_start_is_sealed_into_closed_snapshot() {
        use std::sync::{Arc, atomic::AtomicBool};

        use crate::{
            schema::EntityId,
            session::{AgentId, SessionId, SessionState},
            storage::{Tag, encode_key},
        };

        let dir = tempfile::tempdir().unwrap();
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema_with_session_policy())
            .open()
            .unwrap();

        // Plant an orphaned start record for id 7 (no WAL Start, no snapshot).
        let orphan = SessionState::new(
            SessionId::from_raw(7),
            42,
            AgentId::new("agent").unwrap(),
            "agent".to_string(),
            std::time::Instant::now(),
            crate::schema::Timestamp::now().as_nanos(),
            std::collections::HashMap::new(),
            Arc::new(AtomicBool::new(false)),
        );
        let start_bytes = crate::session::serialize_start_record(&orphan);
        let start_key = encode_key(EntityId::new(7), Tag::Session, b"start");
        let snapshot_key = encode_key(EntityId::new(7), Tag::Session, b"snapshot");
        let engine = db.storage.as_ref().unwrap().items_engine();
        engine.put(&start_key, &start_bytes).unwrap();

        db.restore_sessions();

        // Start key deleted, snapshot key present.
        let engine = db.storage.as_ref().unwrap().items_engine();
        assert!(
            engine.get(&start_key).unwrap().is_none(),
            "the orphaned start record must be deleted after sealing"
        );
        assert!(
            engine.get(&snapshot_key).unwrap().is_some(),
            "a frozen closed snapshot must replace the orphaned start record"
        );
        // Queryable as archived, not active.
        let snap = db.session_snapshot(SessionId::from_raw(7)).unwrap();
        assert_eq!(snap.user_id, 42);
        assert!(
            !db.active_sessions()
                .iter()
                .any(|s| s.id == SessionId::from_raw(7)),
            "a sealed orphan must not appear as an active session"
        );
        // Allocator advanced past the orphan id.
        assert!(
            db.next_session_id.load(Ordering::Acquire) > 7,
            "the allocator must advance past the sealed orphan id"
        );

        // Idempotent: a second restore finds no orphan (snapshot now present) and
        // does not re-warn or re-seal.
        db.restore_sessions();
        let engine = db.storage.as_ref().unwrap().items_engine();
        assert!(engine.get(&start_key).unwrap().is_none());
        db.close().unwrap();
    }

    /// BLOCKER regression (unit-level): `restore_sessions` advances the id
    /// allocator past every durable snapshot id, so a reopen never re-issues an
    /// archived id.
    #[test]
    fn restore_sessions_advances_allocator_over_snapshots() {
        use crate::{
            schema::EntityId,
            storage::{Tag, encode_key},
        };

        let dir = tempfile::tempdir().unwrap();
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema_with_session_policy())
            .open()
            .unwrap();

        // Plant durable snapshots for ids 3 and 11.
        for id in [3u64, 11] {
            let key = encode_key(EntityId::new(id), Tag::Session, b"snapshot");
            db.storage
                .as_ref()
                .unwrap()
                .items_engine()
                .put(&key, &frozen_snapshot_bytes(id, id))
                .unwrap();
        }

        db.restore_sessions();

        assert!(
            db.next_session_id.load(Ordering::Acquire) > 11,
            "the allocator must advance past the highest durable snapshot id (11)"
        );
        db.close().unwrap();
    }
}
