BareGit
//! SQLite-backed durable state owned by one dedicated database thread.

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

use rusqlite::{Connection, OptionalExtension, Transaction, params};
use tokio::sync::{mpsc, oneshot};

use crate::{
    errors::StateStoreError,
    models::{HealthSnapshot, InvocationCandidate, InvocationRecord, InvocationStatus, SyncState},
};

/// Provides asynchronous access to Lisa's authoritative SQLite state.
#[derive(Clone)]
pub struct StateStore {
    sender: mpsc::UnboundedSender<StateCommand>,
}

enum StateCommand {
    SyncState(oneshot::Sender<Result<SyncState, StateStoreError>>),
    RecordBaseline {
        next_batch: String,
        now_ms: u64,
        response: oneshot::Sender<Result<(), StateStoreError>>,
    },
    IngestBatch {
        next_batch: String,
        room_cursors: Vec<(String, Option<String>)>,
        candidates: Vec<InvocationCandidate>,
        now_ms: u64,
        response: oneshot::Sender<Result<usize, StateStoreError>>,
    },
    ClaimNext {
        room_id: String,
        now_ms: u64,
        lease_until_ms: u64,
        response: oneshot::Sender<Result<Option<InvocationRecord>, StateStoreError>>,
    },
    MarkIgnored {
        event_id: String,
        reason: String,
        now_ms: u64,
        response: oneshot::Sender<Result<(), StateStoreError>>,
    },
    StoreReply {
        event_id: String,
        response_json: String,
        now_ms: u64,
        response: oneshot::Sender<Result<(), StateStoreError>>,
    },
    RecordSent {
        event_id: String,
        sent_event_id: String,
        now_ms: u64,
        response: oneshot::Sender<Result<(), StateStoreError>>,
    },
    IsBotEvent {
        room_id: String,
        event_id: String,
        response: oneshot::Sender<Result<bool, StateStoreError>>,
    },
    ReadyToSend(oneshot::Sender<Result<Vec<InvocationRecord>, StateStoreError>>),
    RequeueUnfinished {
        now_ms: u64,
        response: oneshot::Sender<Result<usize, StateStoreError>>,
    },
    Health {
        now_ms: u64,
        response: oneshot::Sender<Result<HealthSnapshot, StateStoreError>>,
    },
    ReleaseProcessing {
        now_ms: u64,
        response: oneshot::Sender<Result<usize, StateStoreError>>,
    },
}

impl StateStore {
    /// Starts the dedicated SQLite owner thread and runs all migrations.
    pub fn open(path: impl AsRef<Path>) -> Result<Self, StateStoreError> {
        let connection = Connection::open(path)?;
        configure_connection(&connection)?;
        migrate(&connection)?;

        let (sender, mut receiver) = mpsc::unbounded_channel();
        thread::Builder::new()
            .name("lisa-state".to_owned())
            .spawn(move || {
                let connection = connection;
                while let Some(command) = receiver.blocking_recv() {
                    handle_command(&connection, command);
                }
            })
            .map_err(|error| StateStoreError::InvalidData(error.to_string()))?;

        Ok(Self { sender })
    }

    /// Returns the persisted controlled-sync cursor.
    pub async fn syncState(&self) -> Result<SyncState, StateStoreError> {
        self.request(StateCommand::SyncState).await
    }

    /// Records an initial sync cursor while deliberately discarding its timeline.
    pub async fn recordBaseline(
        &self,
        next_batch: String,
        now_ms: u64,
    ) -> Result<(), StateStoreError> {
        self.request(|response| StateCommand::RecordBaseline {
            next_batch,
            now_ms,
            response,
        })
        .await
    }

    /// Atomically records a sync cursor, room cursors, and new candidates.
    pub async fn ingestBatch(
        &self,
        next_batch: String,
        room_cursors: Vec<(String, Option<String>)>,
        candidates: Vec<InvocationCandidate>,
        now_ms: u64,
    ) -> Result<usize, StateStoreError> {
        self.request(|response| StateCommand::IngestBatch {
            next_batch,
            room_cursors,
            candidates,
            now_ms,
            response,
        })
        .await
    }

    /// Leases the oldest eligible invocation for one room.
    pub async fn claimNext(
        &self,
        room_id: String,
        now_ms: u64,
        lease_until_ms: u64,
    ) -> Result<Option<InvocationRecord>, StateStoreError> {
        self.request(|response| StateCommand::ClaimNext {
            room_id,
            now_ms,
            lease_until_ms,
            response,
        })
        .await
    }

    /// Marks a candidate as ignored and clears its raw room content.
    pub async fn markIgnored(
        &self,
        event_id: String,
        reason: String,
        now_ms: u64,
    ) -> Result<(), StateStoreError> {
        self.request(|response| StateCommand::MarkIgnored {
            event_id,
            reason,
            now_ms,
            response,
        })
        .await
    }

    /// Persists an exact reply payload before any Matrix send attempt.
    pub async fn storeReply(
        &self,
        event_id: String,
        response_json: String,
        now_ms: u64,
    ) -> Result<(), StateStoreError> {
        self.request(|response| StateCommand::StoreReply {
            event_id,
            response_json,
            now_ms,
            response,
        })
        .await
    }

    /// Records Matrix acceptance and clears sensitive invocation content.
    pub async fn recordSent(
        &self,
        event_id: String,
        sent_event_id: String,
        now_ms: u64,
    ) -> Result<(), StateStoreError> {
        self.request(|response| StateCommand::RecordSent {
            event_id,
            sent_event_id,
            now_ms,
            response,
        })
        .await
    }

    /// Returns whether Lisa previously sent an event in the given room.
    pub async fn isBotEvent(
        &self,
        room_id: String,
        event_id: String,
    ) -> Result<bool, StateStoreError> {
        self.request(|response| StateCommand::IsBotEvent {
            room_id,
            event_id,
            response,
        })
        .await
    }

    /// Returns persisted reply payloads that are safe to retry after a restart.
    pub async fn readyToSend(&self) -> Result<Vec<InvocationRecord>, StateStoreError> {
        self.request(StateCommand::ReadyToSend).await
    }

    /// Requeues expired processing rows and all persisted sends after restart.
    pub async fn requeueUnfinished(&self, now_ms: u64) -> Result<usize, StateStoreError> {
        self.request(|response| StateCommand::RequeueUnfinished { now_ms, response })
            .await
    }

    /// Returns non-sensitive local queue information for an operator health check.
    pub async fn health(&self, now_ms: u64) -> Result<HealthSnapshot, StateStoreError>
    {
        self.request(|response| StateCommand::Health { now_ms, response }).await
    }

    /// Releases active processing leases during a controlled shutdown.
    pub async fn releaseProcessing(&self, now_ms: u64) -> Result<usize, StateStoreError>
    {
        self.request(|response| StateCommand::ReleaseProcessing { now_ms, response }).await
    }

    async fn request<T, F>(&self, make_command: F) -> Result<T, StateStoreError>
    where
        F: FnOnce(oneshot::Sender<Result<T, StateStoreError>>) -> StateCommand,
    {
        let (response_sender, response_receiver) = oneshot::channel();
        self.sender
            .send(make_command(response_sender))
            .map_err(|_| StateStoreError::WorkerStopped)?;
        response_receiver
            .await
            .map_err(|_| StateStoreError::WorkerStopped)?
    }
}

fn handle_command(connection: &Connection, command: StateCommand) {
    match command {
        StateCommand::SyncState(response) => send(response, read_sync_state(connection)),
        StateCommand::RecordBaseline {
            next_batch,
            now_ms,
            response,
        } => send(response, record_baseline(connection, &next_batch, now_ms)),
        StateCommand::IngestBatch {
            next_batch,
            room_cursors,
            candidates,
            now_ms,
            response,
        } => send(
            response,
            ingest_batch(connection, &next_batch, &room_cursors, &candidates, now_ms),
        ),
        StateCommand::ClaimNext {
            room_id,
            now_ms,
            lease_until_ms,
            response,
        } => send(
            response,
            claim_next(connection, &room_id, now_ms, lease_until_ms),
        ),
        StateCommand::MarkIgnored {
            event_id,
            reason,
            now_ms,
            response,
        } => send(
            response,
            mark_ignored(connection, &event_id, &reason, now_ms),
        ),
        StateCommand::StoreReply {
            event_id,
            response_json,
            now_ms,
            response,
        } => send(
            response,
            store_reply(connection, &event_id, &response_json, now_ms),
        ),
        StateCommand::RecordSent {
            event_id,
            sent_event_id,
            now_ms,
            response,
        } => send(
            response,
            record_sent(connection, &event_id, &sent_event_id, now_ms),
        ),
        StateCommand::IsBotEvent {
            room_id,
            event_id,
            response,
        } => send(response, is_bot_event(connection, &room_id, &event_id)),
        StateCommand::ReadyToSend(response) => send(response, ready_to_send(connection)),
        StateCommand::RequeueUnfinished { now_ms, response } => {
            send(response, requeue_unfinished(connection, now_ms));
        }
        StateCommand::Health { now_ms, response } => send(response, health(connection, now_ms)),
        StateCommand::ReleaseProcessing { now_ms, response } => {
            send(response, release_processing(connection, now_ms));
        }
    }
}

fn send<T>(
    response: oneshot::Sender<Result<T, StateStoreError>>,
    result: Result<T, StateStoreError>,
) {
    let _ = response.send(result);
}

fn configure_connection(connection: &Connection) -> Result<(), StateStoreError> {
    connection.execute_batch(
        "PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;",
    )?;
    Ok(())
}

fn migrate(connection: &Connection) -> Result<(), StateStoreError> {
    connection.execute_batch(
        "CREATE TABLE IF NOT EXISTS schema_migrations(
            version INTEGER PRIMARY KEY,
            applied_at_ms INTEGER NOT NULL
        );",
    )?;
    let applied: Option<i64> =
        connection.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
            row.get(0)
        })?;
    if applied.unwrap_or_default() >= 1 {
        return Ok(());
    }
    let transaction = connection.unchecked_transaction()?;
    transaction.execute_batch(
        "CREATE TABLE sync_state(
            singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
            next_batch TEXT,
            initialized INTEGER NOT NULL CHECK(initialized IN (0, 1)),
            updated_at_ms INTEGER NOT NULL
        );
        INSERT INTO sync_state(singleton, next_batch, initialized, updated_at_ms)
        VALUES(1, NULL, 0, 0);
        CREATE TABLE room_cursors(
            room_id TEXT PRIMARY KEY,
            last_timeline_event_id TEXT,
            updated_at_ms INTEGER NOT NULL
        );
        CREATE TABLE invocations(
            event_id TEXT PRIMARY KEY,
            room_id TEXT NOT NULL,
            sender_id TEXT NOT NULL,
            received_at_ms INTEGER NOT NULL,
            status TEXT NOT NULL,
            raw_event_json TEXT,
            response_json TEXT,
            matrix_txn_id TEXT NOT NULL UNIQUE,
            sent_event_id TEXT,
            attempt_count INTEGER NOT NULL DEFAULT 0,
            available_at_ms INTEGER NOT NULL,
            last_error_code TEXT,
            updated_at_ms INTEGER NOT NULL
        );
        CREATE INDEX invocations_ready
        ON invocations(status, available_at_ms, received_at_ms);
        CREATE INDEX invocations_room
        ON invocations(room_id, received_at_ms);
        CREATE TABLE bot_events(
            event_id TEXT PRIMARY KEY,
            room_id TEXT NOT NULL,
            invocation_event_id TEXT NOT NULL UNIQUE,
            sent_at_ms INTEGER NOT NULL,
            FOREIGN KEY(invocation_event_id) REFERENCES invocations(event_id)
        );",
    )?;
    transaction.execute(
        "INSERT INTO schema_migrations(version, applied_at_ms) VALUES(1, 0)",
        [],
    )?;
    transaction.commit()?;
    Ok(())
}

fn read_sync_state(connection: &Connection) -> Result<SyncState, StateStoreError> {
    connection
        .query_row(
            "SELECT initialized, next_batch FROM sync_state WHERE singleton = 1",
            [],
            |row| {
                Ok(SyncState {
                    initialized: row.get::<_, i64>(0)? != 0,
                    next_batch: row.get(1)?,
                })
            },
        )
        .map_err(Into::into)
}

fn record_baseline(
    connection: &Connection,
    next_batch: &str,
    now_ms: u64,
) -> Result<(), StateStoreError> {
    connection.execute(
        "UPDATE sync_state SET next_batch = ?1, initialized = 1, updated_at_ms = ?2 WHERE singleton = 1",
        params![next_batch, now_ms],
    )?;
    Ok(())
}

fn ingest_batch(
    connection: &Connection,
    next_batch: &str,
    room_cursors: &[(String, Option<String>)],
    candidates: &[InvocationCandidate],
    now_ms: u64,
) -> Result<usize, StateStoreError> {
    let transaction = connection.unchecked_transaction()?;
    let mut inserted = 0;
    for candidate in candidates {
        inserted += transaction.execute(
            "INSERT OR IGNORE INTO invocations(
                event_id, room_id, sender_id, received_at_ms, status,
                raw_event_json, matrix_txn_id, available_at_ms, updated_at_ms
            ) VALUES(?1, ?2, ?3, ?4, 'candidate', ?5, ?6, ?7, ?7)",
            params![
                candidate.event_id,
                candidate.room_id,
                candidate.sender_id,
                candidate.received_at_ms,
                candidate.raw_event_json,
                candidate.matrix_txn_id,
                now_ms,
            ],
        )?;
    }
    for (room_id, event_id) in room_cursors {
        transaction.execute(
            "INSERT INTO room_cursors(room_id, last_timeline_event_id, updated_at_ms)
             VALUES(?1, ?2, ?3)
             ON CONFLICT(room_id) DO UPDATE SET
                 last_timeline_event_id = excluded.last_timeline_event_id,
                 updated_at_ms = excluded.updated_at_ms",
            params![room_id, event_id, now_ms],
        )?;
    }
    transaction.execute(
        "UPDATE sync_state SET next_batch = ?1, initialized = 1, updated_at_ms = ?2 WHERE singleton = 1",
        params![next_batch, now_ms],
    )?;
    transaction.commit()?;
    Ok(inserted)
}

fn claim_next(
    connection: &Connection,
    room_id: &str,
    now_ms: u64,
    lease_until_ms: u64,
) -> Result<Option<InvocationRecord>, StateStoreError> {
    let transaction = connection.unchecked_transaction()?;
    let event_id = transaction
        .query_row(
            "SELECT event_id FROM invocations
         WHERE room_id = ?1 AND status = 'candidate' AND available_at_ms <= ?2
         ORDER BY received_at_ms, event_id LIMIT 1",
            params![room_id, now_ms],
            |row| row.get::<_, String>(0),
        )
        .optional()?;
    let Some(event_id) = event_id else {
        transaction.commit()?;
        return Ok(None);
    };
    transaction.execute(
        "UPDATE invocations SET status = 'processing', attempt_count = attempt_count + 1,
         available_at_ms = ?2, updated_at_ms = ?3 WHERE event_id = ?1 AND status = 'candidate'",
        params![event_id, lease_until_ms, now_ms],
    )?;
    let record = read_invocation(&transaction, &event_id)?;
    transaction.commit()?;
    Ok(Some(record))
}

fn mark_ignored(
    connection: &Connection,
    event_id: &str,
    reason: &str,
    now_ms: u64,
) -> Result<(), StateStoreError> {
    let changed = connection.execute(
        "UPDATE invocations SET status = 'ignored', raw_event_json = NULL,
         response_json = NULL, last_error_code = ?2, updated_at_ms = ?3
         WHERE event_id = ?1 AND status IN ('candidate', 'processing')",
        params![event_id, reason, now_ms],
    )?;
    if changed == 0 {
        return Err(StateStoreError::InvalidData(
            "cannot ignore invocation in its current state".to_owned(),
        ));
    }
    Ok(())
}

fn store_reply(
    connection: &Connection,
    event_id: &str,
    response_json: &str,
    now_ms: u64,
) -> Result<(), StateStoreError> {
    let changed = connection.execute(
        "UPDATE invocations SET status = 'ready_to_send', response_json = ?2,
         available_at_ms = ?3, updated_at_ms = ?3
         WHERE event_id = ?1 AND status = 'processing'",
        params![event_id, response_json, now_ms],
    )?;
    if changed == 0 {
        return Err(StateStoreError::InvalidData(
            "cannot store reply for invocation in its current state".to_owned(),
        ));
    }
    Ok(())
}

fn record_sent(
    connection: &Connection,
    event_id: &str,
    sent_event_id: &str,
    now_ms: u64,
) -> Result<(), StateStoreError> {
    let transaction = connection.unchecked_transaction()?;
    let room_id: String = transaction.query_row(
        "SELECT room_id FROM invocations WHERE event_id = ?1 AND status = 'ready_to_send'",
        [event_id],
        |row| row.get(0),
    )?;
    transaction.execute(
        "INSERT INTO bot_events(event_id, room_id, invocation_event_id, sent_at_ms)
         VALUES(?1, ?2, ?3, ?4)",
        params![sent_event_id, room_id, event_id, now_ms],
    )?;
    transaction.execute(
        "UPDATE invocations SET status = 'sent', raw_event_json = NULL,
         response_json = NULL, sent_event_id = ?2, updated_at_ms = ?3 WHERE event_id = ?1",
        params![event_id, sent_event_id, now_ms],
    )?;
    transaction.commit()?;
    Ok(())
}

fn is_bot_event(
    connection: &Connection,
    room_id: &str,
    event_id: &str,
) -> Result<bool, StateStoreError> {
    connection
        .query_row(
            "SELECT EXISTS(SELECT 1 FROM bot_events WHERE room_id = ?1 AND event_id = ?2)",
            params![room_id, event_id],
            |row| row.get::<_, i64>(0),
        )
        .map(|exists| exists != 0)
        .map_err(Into::into)
}

fn ready_to_send(connection: &Connection) -> Result<Vec<InvocationRecord>, StateStoreError> {
    let mut statement = connection.prepare(
        "SELECT event_id, room_id, sender_id, received_at_ms, status, raw_event_json,
         response_json, matrix_txn_id, sent_event_id, attempt_count, available_at_ms,
         last_error_code FROM invocations WHERE status = 'ready_to_send'
         ORDER BY received_at_ms, event_id",
    )?;
    let rows = statement.query_map([], row_to_invocation)?;
    rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}

fn requeue_unfinished(connection: &Connection, now_ms: u64) -> Result<usize, StateStoreError> {
    let changed = connection.execute(
        "UPDATE invocations SET status = 'candidate', available_at_ms = ?1,
         updated_at_ms = ?1 WHERE status = 'processing' AND available_at_ms <= ?1",
        [now_ms],
    )?;
    Ok(changed)
}

fn health(connection: &Connection, now_ms: u64) -> Result<HealthSnapshot, StateStoreError>
{
    let candidate_count = connection.query_row(
        "SELECT COUNT(*) FROM invocations WHERE status = 'candidate'",
        [],
        |row| row.get(0),
    )?;
    let oldest_ready: Option<u64> = connection.query_row(
        "SELECT MIN(updated_at_ms) FROM invocations WHERE status = 'ready_to_send'",
        [],
        |row| row.get(0),
    )?;
    let ready_to_send_count = connection.query_row(
        "SELECT COUNT(*) FROM invocations WHERE status = 'ready_to_send'",
        [],
        |row| row.get(0),
    )?;
    Ok(HealthSnapshot {
        candidate_count,
        ready_to_send_count,
        oldest_ready_age_ms: oldest_ready.map(|timestamp| now_ms.saturating_sub(timestamp)),
    })
}

fn release_processing(connection: &Connection, now_ms: u64) -> Result<usize, StateStoreError>
{
    let changed = connection.execute(
        "UPDATE invocations SET status = 'candidate', available_at_ms = ?1,
         updated_at_ms = ?1 WHERE status = 'processing'",
        [now_ms],
    )?;
    Ok(changed)
}

fn read_invocation(
    transaction: &Transaction<'_>,
    event_id: &str,
) -> Result<InvocationRecord, StateStoreError> {
    transaction
        .query_row(
            "SELECT event_id, room_id, sender_id, received_at_ms, status, raw_event_json,
         response_json, matrix_txn_id, sent_event_id, attempt_count, available_at_ms,
         last_error_code FROM invocations WHERE event_id = ?1",
            [event_id],
            row_to_invocation,
        )
        .map_err(Into::into)
}

fn row_to_invocation(row: &rusqlite::Row<'_>) -> rusqlite::Result<InvocationRecord> {
    let status: String = row.get(4)?;
    let status = InvocationStatus::parse(&status).map_err(|error| {
        rusqlite::Error::FromSqlConversionFailure(
            4,
            rusqlite::types::Type::Text,
            Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, error)),
        )
    })?;
    Ok(InvocationRecord {
        event_id: row.get(0)?,
        room_id: row.get(1)?,
        sender_id: row.get(2)?,
        received_at_ms: row.get(3)?,
        status,
        raw_event_json: row.get(5)?,
        response_json: row.get(6)?,
        matrix_txn_id: row.get(7)?,
        sent_event_id: row.get(8)?,
        attempt_count: row.get(9)?,
        available_at_ms: row.get(10)?,
        last_error_code: row.get(11)?,
    })
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use super::StateStore;
    use crate::models::{InvocationCandidate, InvocationStatus};

    fn candidate(event_id: &str) -> InvocationCandidate {
        InvocationCandidate {
            event_id: event_id.to_owned(),
            room_id: "!room:example.test".to_owned(),
            sender_id: "@human:example.test".to_owned(),
            received_at_ms: 10,
            raw_event_json: "{}".to_owned(),
            matrix_txn_id: format!("txn_{event_id}"),
        }
    }

    #[tokio::test]
    async fn deduplicates_candidates_and_preserves_cursor() {
        let directory = tempdir().unwrap();
        let store = StateStore::open(directory.path().join("state.sqlite3")).unwrap();
        let first = store
            .ingestBatch("s1".to_owned(), vec![], vec![candidate("$one")], 20)
            .await
            .unwrap();
        let second = store
            .ingestBatch("s2".to_owned(), vec![], vec![candidate("$one")], 30)
            .await
            .unwrap();

        assert_eq!(first, 1);
        assert_eq!(second, 0);
        assert_eq!(
            store.syncState().await.unwrap().next_batch.as_deref(),
            Some("s2")
        );
    }

    #[tokio::test]
    async fn persists_reply_before_recording_single_sent_event() {
        let directory = tempdir().unwrap();
        let store = StateStore::open(directory.path().join("state.sqlite3")).unwrap();
        store
            .ingestBatch("s1".to_owned(), vec![], vec![candidate("$one")], 20)
            .await
            .unwrap();
        let invocation = store
            .claimNext("!room:example.test".to_owned(), 21, 100)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(invocation.status, InvocationStatus::Processing);
        store
            .storeReply("$one".to_owned(), "{\"body\":\"test\"}".to_owned(), 22)
            .await
            .unwrap();
        let ready = store.readyToSend().await.unwrap();
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].matrix_txn_id, "txn_$one");
        assert_eq!(
            ready[0].response_json.as_deref(),
            Some("{\"body\":\"test\"}")
        );
        store
            .recordSent("$one".to_owned(), "$reply".to_owned(), 23)
            .await
            .unwrap();

        assert!(
            store
                .isBotEvent("!room:example.test".to_owned(), "$reply".to_owned())
                .await
                .unwrap()
        );
        assert!(
            store
                .recordSent("$one".to_owned(), "$reply".to_owned(), 24)
                .await
                .is_err()
        );
    }

    #[tokio::test]
    async fn requeues_only_an_expired_processing_lease() {
        let directory = tempdir().unwrap();
        let store = StateStore::open(directory.path().join("state.sqlite3")).unwrap();
        store
            .ingestBatch("s1".to_owned(), vec![], vec![candidate("$one")], 20)
            .await
            .unwrap();
        store
            .claimNext("!room:example.test".to_owned(), 21, 100)
            .await
            .unwrap();

        assert_eq!(store.requeueUnfinished(99).await.unwrap(), 0);
        assert_eq!(store.requeueUnfinished(100).await.unwrap(), 1);
        assert!(
            store
                .claimNext("!room:example.test".to_owned(), 101, 200)
                .await
                .unwrap()
                .is_some()
        );
    }

    #[tokio::test]
    async fn health_exposes_counts_without_invocation_content() {
        let directory = tempdir().unwrap();
        let store = StateStore::open(directory.path().join("state.sqlite3")).unwrap();
        store.ingestBatch("s1".to_owned(), vec![], vec![candidate("$one")], 20)
            .await.unwrap();
        let health = store.health(30).await.unwrap();
        assert_eq!(health.candidate_count, 1);
        assert_eq!(health.ready_to_send_count, 0);
        assert_eq!(health.oldest_ready_age_ms, None);
    }
}