pub(crate) mod escalation;
pub(crate) mod handoff;
pub(crate) mod host_loop;
pub(crate) mod memory_evidence;
pub(crate) mod memory_ops;
pub(crate) mod migration;
pub(crate) mod projection;
pub(crate) mod projection_cache;
pub(crate) mod recovery;
pub(crate) mod schema;
pub(crate) mod session;
pub(crate) mod session_activity;
pub(crate) mod session_gates;
pub(crate) mod telemetry_cost;
pub(crate) mod work_stream_decay;

use std::path::{Component, Path};

use anyhow::{Context, Result};
use rusqlite::{Connection, Error as SqliteError, ErrorCode};
use tracing::debug;

use crate::paths::state::StateLayout;

pub(crate) struct StateDb {
    conn: Connection,
}

impl StateDb {
    /// Low-level opener.
    ///
    /// `pods_root` controls the RFC 0006 M4(c) schema-bump gate. Callers that
    /// hold a [`StateLayout`] should derive `pods_root` from
    /// `layout.pods_root()` so the gate checks the layout's own pod tree
    /// instead of a global HOME-derived path (which would diverge for
    /// non-default layouts, tests, or explicit ccd_root overrides). Pass
    /// `None` to opt out of the gate entirely — intended for test fixtures
    /// that construct an in-memory DB without a layout.
    pub(crate) fn open(path: &Path, pods_root: Option<&Path>) -> Result<Self> {
        let needs_init = !path.exists();
        debug!(path = %path.display(), needs_init, "opening state.db");
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("create state.db parent: {}", parent.display()))?;
        }
        let conn = Connection::open(path)
            .map_err(|error| wrap_state_db_open_error(path, needs_init, error))?;
        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
        // Configure a busy timeout so `BEGIN IMMEDIATE` and other lock
        // acquisitions wait for contending writers instead of failing
        // fast with `SQLITE_BUSY`. Without this, the session-lifecycle
        // transactions introduced for ccd#568 would trade the silent
        // lost-update race for spurious `database is locked` errors
        // under overlapping `start` / `clear` / `heartbeat`
        // invocations (especially from autonomous workers whose
        // heartbeats can overlap with supervisor writes). Five seconds
        // is long enough for a well-behaved body to commit — every
        // current closure body performs a bounded number of small
        // writes with no external I/O — and short enough that a truly
        // wedged writer still surfaces an error inside operator
        // patience. Applies to every `StateDb` caller, not only
        // session lifecycle, so concurrent radar-state reads against
        // an in-flight lifecycle write also wait rather than fail.
        conn.busy_timeout(std::time::Duration::from_secs(5))?;
        if needs_init {
            debug!("initializing fresh state.db schema");
            schema::initialize(&conn, pods_root)?;
        } else {
            debug!("running state.db schema migration check");
            schema::migrate(&conn, pods_root)?;
        }
        debug!(path = %path.display(), "state.db ready");
        Ok(Self { conn })
    }

    pub(crate) fn open_with_migration(path: &Path, layout: &StateLayout) -> Result<Self> {
        let pods_root = layout.pods_root();
        let db = Self::open(path, Some(&pods_root))?;
        // Always check for legacy files — handles both fresh DBs and
        // previously aborted migrations where the DB exists but some
        // legacy files were not yet imported/renamed.
        let report = migration::import_legacy_json(&db, layout)?;
        if report.imported_count > 0 {
            eprintln!(
                "ccd: migrated {} legacy state file(s) to state.db",
                report.imported_count
            );
        }
        Ok(db)
    }

    /// Convenience: resolve path from layout and open with migration.
    pub(crate) fn open_for_layout(layout: &StateLayout) -> Result<Self> {
        Self::open_with_migration(&layout.state_db_path(), layout)
    }

    /// Probe-based opener for read paths that want to share a handle. Returns
    /// `Some` only if `state.db` already exists — callers that see `None`
    /// should fall back to per-module readers so legacy JSON migrations
    /// continue to flow through their usual module-owned paths.
    ///
    /// Intentionally skips `open_with_migration`: module-owned migrations
    /// (`migrate_session_json`, `migrate_projection_json`, …) own their own
    /// idempotency guards, whereas `import_legacy_json`'s projection path
    /// unconditionally deletes existing rows before re-importing. Sharing a
    /// handle for reads must not bundle that destructive import.
    pub(crate) fn try_open_for_layout(layout: &StateLayout) -> Result<Option<Self>> {
        if layout.state_db_path().exists() {
            let pods_root = layout.pods_root();
            Self::open(&layout.state_db_path(), Some(&pods_root)).map(Some)
        } else {
            Ok(None)
        }
    }

    /// Probe-based opener for per-module read paths that also own a
    /// legacy-JSON migration. Opens only if `state.db` already exists or
    /// `legacy_present()` returns true, then runs the module's `migrate`
    /// closure.
    ///
    /// Callers pass `legacy_present` because each module knows which legacy
    /// path(s) it is responsible for. The probe is a closure (not a `bool`)
    /// so the common case where `state.db` already exists short-circuits
    /// before any legacy `stat` syscalls run — matching the per-module
    /// helpers' behavior before consolidation.
    ///
    /// Returning `None` lets read APIs stay side-effect-free on trees that
    /// have neither the DB nor any legacy artifacts yet (including
    /// clone-local trees that only hold unrelated subsystem state).
    ///
    /// Unlike [`Self::try_open_for_layout`], this helper DOES run migration —
    /// but only the closure the caller provides. Each module-owned migration
    /// has its own idempotency guard, so it is safe to re-run when the DB
    /// already holds data. Do not use this helper for write paths; writers
    /// should unconditionally open via [`Self::open_for_layout`] or
    /// [`Self::open`].
    pub(crate) fn try_open_with_migration<P, F>(
        layout: &StateLayout,
        legacy_present: P,
        migrate: F,
    ) -> Result<Option<Self>>
    where
        P: FnOnce() -> bool,
        F: FnOnce(&Self) -> Result<()>,
    {
        if layout.state_db_path().exists() || legacy_present() {
            let pods_root = layout.pods_root();
            let db = Self::open(&layout.state_db_path(), Some(&pods_root))?;
            migrate(&db)?;
            Ok(Some(db))
        } else {
            Ok(None)
        }
    }

    pub(crate) fn conn(&self) -> &Connection {
        &self.conn
    }
}

fn wrap_state_db_open_error(path: &Path, needs_init: bool, error: SqliteError) -> anyhow::Error {
    let is_cannot_open =
        matches!(&error, SqliteError::SqliteFailure(err, _) if err.code == ErrorCode::CannotOpen);
    let base = anyhow::Error::new(error).context(format!("open state.db: {}", path.display()));
    if needs_init && is_linked_worktree_clone_state_path(path) && is_cannot_open {
        return base.context(format!(
            "first-create failed for workspace-local state under the shared linked-worktree gitdir at {}; some sandboxed environments can read the checkout but cannot create new files under `.git/worktrees/...`. Re-run this command once with broader filesystem access or pre-create `state.db` outside the sandbox",
            path.display()
        ));
    }

    base
}

fn is_linked_worktree_clone_state_path(path: &Path) -> bool {
    let mut saw_dot_git = false;
    for component in path.components() {
        let Component::Normal(segment) = component else {
            continue;
        };

        if !saw_dot_git {
            if segment == ".git" {
                saw_dot_git = true;
            }
            continue;
        }

        return segment == "worktrees";
    }

    false
}

#[cfg(test)]
mod integration_tests {
    use std::fs;

    use tempfile::tempdir;

    use super::*;
    use crate::profile::ProfileName;
    use crate::state::escalation::{EscalationEntry, EscalationKind};
    use crate::state::projection_metadata::ProjectionObservation;
    use crate::state::runtime::{
        RecoveryOrigin, RuntimeCheckpointState, RuntimeHandoffItem, RuntimeHandoffState,
        RuntimeLifecycle, RuntimeWorkingBufferState,
    };
    use crate::state::session::SessionStateFile;

    fn test_layout(temp: &Path) -> StateLayout {
        StateLayout::new(
            temp.join(".ccd"),
            temp.join("repo/.git/ccd"),
            ProfileName::new("main").expect("profile"),
        )
    }

    fn setup_dirs(layout: &StateLayout) {
        fs::create_dir_all(layout.clone_profile_root()).expect("clone profile root");
        fs::create_dir_all(layout.clone_runtime_state_root()).expect("clone runtime root");
    }

    fn interactive_session(
        schema_version: u32,
        started_at_epoch_s: u64,
        last_started_at_epoch_s: u64,
        start_count: u32,
        session_id: Option<&str>,
        mode: crate::state::session::SessionMode,
    ) -> SessionStateFile {
        SessionStateFile {
            schema_version,
            started_at_epoch_s,
            last_started_at_epoch_s,
            start_count,
            session_id: session_id.map(str::to_owned),
            mode,
            owner_kind: crate::state::session::SessionOwnerKind::Interactive,
            owner_id: session_id.map(|_| "interactive".to_owned()),
            supervisor_id: None,
            lease_ttl_secs: None,
            last_heartbeat_at_epoch_s: None,
            revision: u64::from(session_id.is_some()),
        }
    }

    // --- Lifecycle: open, write, read across all tables ---

    #[test]
    fn full_lifecycle_across_all_tables() {
        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        let db = StateDb::open_for_layout(&layout).unwrap();

        // Session
        let sess = interactive_session(
            3,
            1_000_000,
            1_000_050,
            2,
            Some("ses_LIFECYCLE"),
            crate::state::session::SessionMode::Research,
        );
        session::write(db.conn(), &sess).unwrap();
        let loaded_sess = session::read(db.conn()).unwrap().expect("session");
        assert_eq!(loaded_sess.session_id.as_deref(), Some("ses_LIFECYCLE"));
        assert_eq!(loaded_sess.start_count, 2);
        assert_eq!(
            loaded_sess.mode,
            crate::state::session::SessionMode::Research
        );

        // Handoff
        let ho = RuntimeHandoffState {
            title: "Integration test".to_owned(),
            immediate_actions: vec![RuntimeHandoffItem {
                text: "Run full lifecycle test.".to_owned(),
                lifecycle: RuntimeLifecycle::Active,
            }],
            completed_state: vec![RuntimeHandoffItem {
                text: "Schema created.".to_owned(),
                lifecycle: RuntimeLifecycle::Active,
            }],
            ..RuntimeHandoffState::default()
        };
        handoff::write(db.conn(), &ho).unwrap();
        let loaded_ho = handoff::read(db.conn()).unwrap().expect("handoff");
        assert_eq!(loaded_ho.title, "Integration test");
        assert_eq!(loaded_ho.immediate_actions.len(), 1);
        assert_eq!(loaded_ho.completed_state.len(), 1);

        // Escalation
        let esc = EscalationEntry {
            id: "esc_INT_1".to_owned(),
            kind: EscalationKind::Blocking,
            reason: "integration test".to_owned(),
            created_at_epoch_s: 1_000_000,
            session_id: Some("ses_LIFECYCLE".to_owned()),
        };
        escalation::insert(db.conn(), &esc).unwrap();
        assert!(escalation::has_blocking(db.conn()).unwrap());

        let entries = escalation::list(db.conn()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].id, "esc_INT_1");

        // Recovery — checkpoint
        let cp = RuntimeCheckpointState {
            origin: RecoveryOrigin::Compaction,
            captured_at_epoch_s: 1_000_100,
            session_started_at_epoch_s: 1_000_000,
            summary: "Full lifecycle checkpoint".to_owned(),
            immediate_actions: vec!["Next step".to_owned()],
            key_files: vec!["src/db/mod.rs".to_owned()],
        };
        recovery::write_checkpoint(db.conn(), &cp).unwrap();
        let loaded_cp = recovery::read_checkpoint(db.conn())
            .unwrap()
            .expect("checkpoint");
        assert_eq!(loaded_cp.summary, "Full lifecycle checkpoint");

        // Recovery — working buffer
        let buf = RuntimeWorkingBufferState {
            origin: RecoveryOrigin::RiskyPause,
            captured_at_epoch_s: 1_000_200,
            session_started_at_epoch_s: 1_000_000,
            summary_lines: vec!["Line 1".to_owned(), "Line 2".to_owned()],
        };
        recovery::write_working_buffer(db.conn(), &buf).unwrap();
        let loaded_buf = recovery::read_working_buffer(db.conn())
            .unwrap()
            .expect("buffer");
        assert_eq!(loaded_buf.summary_lines.len(), 2);

        // Projection
        let obs = ProjectionObservation {
            observed_at_epoch_s: 1_000_300,
            source_fingerprint: "fp_lifecycle".to_owned(),
            projection_digests: None,
            tool_surface_fingerprint: Some("tool_v1".to_owned()),
            session_id: None,
        };
        projection::record(db.conn(), &obs).unwrap();
        let projections = projection::list(db.conn()).unwrap();
        assert_eq!(projections.len(), 1);
        assert_eq!(projections[0].source_fingerprint, "fp_lifecycle");
    }

    // --- Migration: legacy JSON files imported on first open ---

    #[test]
    fn open_for_layout_migrates_legacy_json() {
        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        // Write legacy session file
        fs::write(
            layout.session_state_path(),
            r#"{"schema_version":3,"started_at_epoch_s":5000,"last_started_at_epoch_s":6000,"start_count":4,"session_id":"ses_LEGACY"}"#,
        ).unwrap();

        // Write legacy handoff file
        fs::write(
            layout.clone_runtime_state_path(),
            r#"{"schema_version":1,"handoff":{"title":"Legacy handoff","immediate_actions":[{"text":"Migrated action.","lifecycle":"active"}],"completed_state":[],"operational_guardrails":[],"key_files":[],"definition_of_done":[]}}"#,
        ).unwrap();

        // Write legacy escalation file
        fs::write(
            layout.escalation_state_path(),
            r#"{"schema_version":1,"entries":[{"id":"esc_LEGACY","kind":"non_blocking","reason":"legacy escalation","created_at_epoch_s":7000}]}"#,
        ).unwrap();

        let db = StateDb::open_for_layout(&layout).unwrap();

        // Verify migration
        let sess = session::read(db.conn()).unwrap().expect("migrated session");
        assert_eq!(sess.session_id.as_deref(), Some("ses_LEGACY"));
        assert_eq!(sess.start_count, 4);

        let ho = handoff::read(db.conn()).unwrap().expect("migrated handoff");
        assert_eq!(ho.title, "Legacy handoff");
        assert_eq!(ho.immediate_actions.len(), 1);

        let entries = escalation::list(db.conn()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].id, "esc_LEGACY");
        assert_eq!(entries[0].kind, EscalationKind::NonBlocking);

        // Legacy files renamed
        assert!(!layout.session_state_path().exists());
        assert!(!layout.clone_runtime_state_path().exists());
        assert!(!layout.escalation_state_path().exists());

        let mut migrated = layout.session_state_path().as_os_str().to_owned();
        migrated.push(".migrated");
        assert!(Path::new(&migrated).exists());
    }

    // --- Second open skips migration ---

    #[test]
    fn second_open_skips_migration() {
        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        // First open — creates empty DB
        {
            let db = StateDb::open_for_layout(&layout).unwrap();
            let sess = interactive_session(
                3,
                1000,
                2000,
                1,
                Some("ses_FIRST"),
                crate::state::session::SessionMode::General,
            );
            session::write(db.conn(), &sess).unwrap();
        }

        // Second open — should NOT re-initialize or lose data
        let db = StateDb::open_for_layout(&layout).unwrap();
        let loaded = session::read(db.conn())
            .unwrap()
            .expect("session persisted");
        assert_eq!(loaded.session_id.as_deref(), Some("ses_FIRST"));
    }

    // --- Regeneration: deleted state.db starts fresh ---

    #[test]
    fn deleted_state_db_regenerates_cleanly() {
        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        // Create and populate
        {
            let db = StateDb::open_for_layout(&layout).unwrap();
            session::write(
                db.conn(),
                &interactive_session(
                    3,
                    1000,
                    2000,
                    1,
                    Some("ses_GONE"),
                    crate::state::session::SessionMode::General,
                ),
            )
            .unwrap();
        }

        // Delete the DB
        fs::remove_file(layout.state_db_path()).unwrap();

        // Reopen — should create fresh empty DB, not crash
        let db = StateDb::open_for_layout(&layout).unwrap();
        assert!(session::read(db.conn()).unwrap().is_none());

        // Should be functional
        session::write(
            db.conn(),
            &interactive_session(
                3,
                3000,
                4000,
                1,
                Some("ses_NEW"),
                crate::state::session::SessionMode::General,
            ),
        )
        .unwrap();
        let loaded = session::read(db.conn()).unwrap().expect("fresh session");
        assert_eq!(loaded.session_id.as_deref(), Some("ses_NEW"));
    }

    // --- Session lifecycle: start → increment → stale reset ---

    #[test]
    fn session_lifecycle_start_increment_stale_reset() {
        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        let db = StateDb::open_for_layout(&layout).unwrap();
        let now = 1_000_000u64;

        // Fresh start
        let state1 = interactive_session(
            3,
            now,
            now,
            1,
            Some("ses_START"),
            crate::state::session::SessionMode::General,
        );
        session::write(db.conn(), &state1).unwrap();

        // Non-stale — should get session ID
        let sid = session::load_session_id(db.conn(), now + 100).unwrap();
        assert_eq!(sid.as_deref(), Some("ses_START"));

        // Stale — should get None
        let stale_sid = session::load_session_id(db.conn(), now + 8 * 3600 + 1).unwrap();
        assert!(stale_sid.is_none());
    }

    // --- Escalation lifecycle: insert → clear non-blocking → clear all ---

    #[test]
    fn escalation_lifecycle() {
        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        let db = StateDb::open_for_layout(&layout).unwrap();

        // Insert blocking + non-blocking
        escalation::insert(
            db.conn(),
            &EscalationEntry {
                id: "esc_B1".to_owned(),
                kind: EscalationKind::Blocking,
                reason: "critical".to_owned(),
                created_at_epoch_s: 1000,
                session_id: Some("ses_1".to_owned()),
            },
        )
        .unwrap();
        escalation::insert(
            db.conn(),
            &EscalationEntry {
                id: "esc_NB1".to_owned(),
                kind: EscalationKind::NonBlocking,
                reason: "info".to_owned(),
                created_at_epoch_s: 2000,
                session_id: Some("ses_1".to_owned()),
            },
        )
        .unwrap();
        escalation::insert(
            db.conn(),
            &EscalationEntry {
                id: "esc_NB2".to_owned(),
                kind: EscalationKind::NonBlocking,
                reason: "info 2".to_owned(),
                created_at_epoch_s: 3000,
                session_id: None,
            },
        )
        .unwrap();

        assert!(escalation::has_blocking(db.conn()).unwrap());
        assert_eq!(escalation::list(db.conn()).unwrap().len(), 3);

        // Clear non-blocking (stale reset behavior)
        let cleared = escalation::clear_non_blocking(db.conn()).unwrap();
        assert_eq!(cleared, 2);
        assert!(escalation::has_blocking(db.conn()).unwrap());
        assert_eq!(escalation::list(db.conn()).unwrap().len(), 1);

        // Clear all (session boundary behavior)
        escalation::clear_all(db.conn()).unwrap();
        assert!(!escalation::has_blocking(db.conn()).unwrap());
        assert!(escalation::list(db.conn()).unwrap().is_empty());
    }

    // --- Handoff overwrite preserves lifecycle values ---

    #[test]
    fn handoff_preserves_inactive_items() {
        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        let db = StateDb::open_for_layout(&layout).unwrap();

        let ho = RuntimeHandoffState {
            title: "Mixed lifecycle".to_owned(),
            immediate_actions: vec![
                RuntimeHandoffItem {
                    text: "Active action.".to_owned(),
                    lifecycle: RuntimeLifecycle::Active,
                },
                RuntimeHandoffItem {
                    text: "Archived action.".to_owned(),
                    lifecycle: RuntimeLifecycle::Inactive,
                },
            ],
            ..RuntimeHandoffState::default()
        };

        handoff::write(db.conn(), &ho).unwrap();
        let loaded = handoff::read(db.conn()).unwrap().expect("handoff");

        assert_eq!(loaded.immediate_actions.len(), 2);
        assert_eq!(
            loaded.immediate_actions[0].lifecycle,
            RuntimeLifecycle::Active
        );
        assert_eq!(
            loaded.immediate_actions[1].lifecycle,
            RuntimeLifecycle::Inactive
        );
    }

    // --- Shared-handle helpers migrate legacy JSON onto caller's handle ---
    //
    // Regression coverage for the Phase A.3 concern raised by reviewers:
    // `try_open_for_layout` is a non-migrating opener, so `_from_db` readers
    // that reuse its handle must not silently bypass legacy JSON that the
    // migrate-on-open path would have picked up. The `_from_shared_db`
    // helpers run their per-module migration idempotently against the
    // shared handle before reading.

    #[test]
    fn baseline_from_shared_db_migrates_legacy_projection_metadata_json() {
        use crate::state::projection_metadata::{
            self as projection_metadata, ProjectionMetadataFile,
        };

        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        // Seed `state.db` via a different module so legacy projection JSON
        // has not been migrated yet.
        let _ = StateDb::open(&layout.state_db_path(), None).unwrap();

        // Drop a legacy `projection_metadata.json` carrying a baseline.
        let observation = ProjectionObservation {
            observed_at_epoch_s: 1_700_000_000,
            source_fingerprint: "fp-unit-test".to_owned(),
            projection_digests: Some(crate::state::compiled::ProjectionDigests::default()),
            tool_surface_fingerprint: None,
            session_id: None,
        };
        let metadata = ProjectionMetadataFile {
            schema_version: 1,
            observations: vec![observation.clone()],
        };
        let legacy_path = layout.clone_projection_metadata_path();
        fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
        fs::write(&legacy_path, serde_json::to_string(&metadata).unwrap()).unwrap();

        // Reproduce the radar-state path: obtain the shared handle via the
        // non-migrating opener.
        let shared = StateDb::try_open_for_layout(&layout)
            .unwrap()
            .expect("state.db exists so try_open_for_layout returns Some");

        // Before migration runs, the DB has no observations.
        assert!(
            projection::list(shared.conn()).unwrap().is_empty(),
            "precondition: DB starts empty",
        );
        assert!(
            legacy_path.exists(),
            "precondition: legacy JSON still on disk",
        );

        // Call the shared-db variant that should run migration first.
        // The lookup itself returns None because no row binds
        // `ses_SHARED_DB_TEST`, but the *migration side effect* is what we
        // are verifying — the legacy file must move off disk and into the
        // DB so a later `_from_db` reader reuses this shared handle without
        // losing observations.
        let _ = projection_metadata::load_baseline_for_session_from_shared_db(
            &shared,
            &layout,
            "ses_SHARED_DB_TEST",
        )
        .unwrap();

        // Migration triggered: legacy file renamed to `.migrated`, DB now
        // holds the observation.
        assert!(
            !legacy_path.exists(),
            "legacy JSON must be renamed after migration",
        );
        let mut migrated = legacy_path.as_os_str().to_owned();
        migrated.push(".migrated");
        assert!(
            Path::new(&migrated).exists(),
            ".migrated sentinel must exist",
        );
        let rows = projection::list(shared.conn()).unwrap();
        assert_eq!(
            rows.len(),
            1,
            "exactly one observation imported into the shared handle",
        );
        assert_eq!(rows[0].source_fingerprint, "fp-unit-test");
    }

    #[test]
    fn session_id_from_shared_db_migrates_legacy_session_state_json() {
        use crate::state::session as session_state;

        let temp = tempdir().unwrap();
        let layout = test_layout(temp.path());
        setup_dirs(&layout);

        // Seed `state.db` via a different module so session JSON is
        // unmigrated.
        let _ = StateDb::open(&layout.state_db_path(), None).unwrap();

        // Drop a live interactive session in legacy JSON. Use a recent
        // heartbeat so the session id is not treated as stale.
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let sess = interactive_session(
            3,
            now - 60,
            now - 10,
            1,
            Some("ses_LEGACY_SESSION"),
            crate::state::session::SessionMode::General,
        );
        let legacy_path = layout.session_state_path();
        fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
        fs::write(&legacy_path, serde_json::to_string(&sess).unwrap()).unwrap();

        let shared = StateDb::try_open_for_layout(&layout)
            .unwrap()
            .expect("state.db exists so try_open_for_layout returns Some");

        // Bare variant does not run migration.
        assert!(session_state::load_session_id_from_db(Some(&shared))
            .unwrap()
            .is_none());

        // Shared-db-with-migration variant picks it up.
        let resolved = session_state::load_session_id_from_shared_db(&shared, &layout).unwrap();
        assert_eq!(resolved.as_deref(), Some("ses_LEGACY_SESSION"));

        assert!(!legacy_path.exists());
    }

    // --- Fail-closed: unwritable path ---

    #[test]
    fn open_fails_cleanly_on_unwritable_path() {
        // Attempt to open in a non-existent deeply nested path that we can't create
        // (root-owned directory). This is a best-effort test — skip if running as root.
        let result = StateDb::open(Path::new("/proc/nonexistent/state.db"), None);
        let err_msg = match result {
            Err(e) => e.to_string(),
            Ok(_) => panic!("expected error for unwritable path"),
        };
        assert!(
            err_msg.contains("state.db"),
            "error should reference state.db: {err_msg}"
        );
    }
}
