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

use crossbeam::channel::{Receiver, RecvTimeoutError};

use crate::{schema::EntityId, text::index::TextIndex};

/// Initial backoff after a failed commit, doubled on each consecutive failure.
const COMMIT_RETRY_BACKOFF_INITIAL: Duration = Duration::from_millis(100);

/// Ceiling on the commit-retry backoff so a long outage does not stall retries
/// indefinitely while still keeping a tight retry cadence for transient blips.
const COMMIT_RETRY_BACKOFF_MAX: Duration = Duration::from_secs(30);

/// Consecutive commit failures after which the syncer marks the text index
/// unhealthy. It keeps retrying past this point — the flag is a *signal* to the
/// DB (`health_check` / `flush_text_index`) that search may be serving stale results
/// and a rebuild is warranted, not a reason to stop draining the channel.
const COMMIT_FAILURES_BEFORE_UNHEALTHY: u32 = 3;

/// A pending write event sent to the background syncer.
///
/// Produced by the entity write path (`write_item_with_metadata`) and consumed
/// by the background [`TextIndexSyncer`] thread. Each event represents a single
/// document that should be indexed or deleted in Tantivy.
#[derive(Debug, Clone)]
pub struct PendingWrite {
    /// The entity ID of the item being written or deleted.
    pub entity_id: EntityId,
    /// The metadata key-value map for the item. Ignored when `deleted` is true.
    pub metadata: HashMap<String, String>,
    /// When true, the entity should be removed from the text index.
    pub deleted: bool,
}

/// Background thread that feeds the Tantivy text index from an outbox channel.
///
/// Receives [`PendingWrite`] events from the entity write path and batches them
/// into Tantivy commits. Commits after `commit_every_n` documents OR after
/// `commit_every` seconds, whichever comes first. On channel close (graceful
/// shutdown), flushes any remaining pending documents before exiting.
///
/// The syncer holds the Tantivy writer lock for its entire lifetime. This is
/// correct because Tantivy enforces single-writer semantics -- there is no
/// benefit to releasing and re-acquiring the lock between commits.
///
/// Optionally accepts a flush channel: when the caller sends a one-shot
/// `Sender<()>` on the flush channel, the syncer immediately commits any
/// pending documents and acknowledges the flush. This enables synchronous
/// flush semantics for tests and benchmarks, replacing sleep-based waits.
pub struct TextIndexSyncer {
    index: Arc<TextIndex>,
    rx: Receiver<PendingWrite>,
    commit_every_n: usize,
    commit_every: Duration,
    /// Optional channel for synchronous flush requests. When a `Sender<()>` is
    /// received, the syncer commits any pending writes and sends `()` back on
    /// the one-shot to acknowledge the flush.
    flush_rx: Option<Receiver<crossbeam::channel::Sender<()>>>,
    /// Shared liveness flag, aliasing [`TextIndex`]'s health flag. Set `false`
    /// after [`COMMIT_FAILURES_BEFORE_UNHEALTHY`] consecutive commit failures so
    /// the DB can surface degraded text search; reset `true` once a commit
    /// succeeds again.
    healthy: Arc<AtomicBool>,
}

impl TextIndexSyncer {
    /// Create a new syncer.
    ///
    /// - `index`: The shared text index to write into.
    /// - `rx`: The receiving end of the outbox channel.
    /// - `commit_every_n`: Commit after this many documents are buffered.
    /// - `commit_every_secs`: Commit after this many seconds of wall-clock time
    ///   since the last commit, even if the batch is not full.
    ///
    /// Both thresholds are clamped to a sane minimum of `1`: a `0` document
    /// threshold would commit on every single write (defeating batching and
    /// fsyncing per document), and a `0`-second time threshold would commit on
    /// every idle poll tick, spinning the writer on empty batches. Clamping
    /// upward is correct because a `0` here is always a misconfiguration, never
    /// an intentional "never commit" — the syncer must always make progress.
    pub fn new(
        index: Arc<TextIndex>,
        rx: Receiver<PendingWrite>,
        commit_every_n: usize,
        commit_every_secs: u64,
    ) -> Self {
        let healthy = index.health_flag();
        Self {
            index,
            rx,
            commit_every_n: commit_every_n.max(1),
            commit_every: Duration::from_secs(commit_every_secs.max(1)),
            flush_rx: None,
            healthy,
        }
    }

    /// Attach a flush channel for synchronous commit requests.
    ///
    /// When the syncer receives a `Sender<()>` on this channel, it immediately
    /// commits any pending writes and sends `()` back to acknowledge the flush.
    #[must_use]
    pub fn with_flush_rx(mut self, rx: Receiver<crossbeam::channel::Sender<()>>) -> Self {
        self.flush_rx = Some(rx);
        self
    }

    /// Run the syncer loop. Blocks until the channel is closed (sender dropped).
    /// Intended to run on a dedicated background thread.
    ///
    /// # Resilience
    ///
    /// A single failed Tantivy commit must NOT kill the syncer. The only
    /// production producer (`write_item_with_metadata`) sends best-effort into
    /// an unbounded channel, so a dead syncer silently and permanently loses
    /// every future write from the text index. Instead, a failed commit is
    /// logged, the buffered batch is retained (Tantivy keeps un-committed docs),
    /// and the commit is retried on the next loop iteration with exponential
    /// backoff. After [`COMMIT_FAILURES_BEFORE_UNHEALTHY`] consecutive failures
    /// the shared health flag flips to `false` so the DB can surface degraded
    /// text search and trigger a rebuild; the syncer keeps retrying regardless.
    ///
    /// The loop exits only on a `Disconnected` channel (graceful shutdown). It
    /// returns the last commit error to the joining thread for logging, but the
    /// loop itself never aborts mid-stream on a commit failure.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if the writer lock is poisoned at startup,
    /// or if the final shutdown flush fails after exhausting retries.
    #[allow(clippy::significant_drop_tightening)] // writer is intentionally held for the entire run() -- Tantivy single-writer design.
    pub fn run(self) -> crate::Result<()> {
        let mut writer = self.index.writer_guard()?;

        let mut pending_count: usize = 0;
        let mut last_commit = Instant::now();
        // Consecutive commit-failure counter and current backoff. Reset to 0 /
        // initial whenever a commit succeeds.
        let mut consecutive_failures: u32 = 0;
        let mut backoff = COMMIT_RETRY_BACKOFF_INITIAL;
        // When the previous commit failed, do not re-attempt until this instant.
        let mut retry_not_before: Option<Instant> = None;

        loop {
            // ── Flush requests ──────────────────────────────────────────────
            // When a caller sends a one-shot Sender<()>, drain all pending
            // writes, commit, then acknowledge so the caller sees every write
            // that preceded the flush. A commit failure here does NOT drop the
            // ack: we still acknowledge (the caller observes Ok but a later
            // health_check reveals the degraded state), and the batch is retried
            // on the next loop iteration. Retaining the un-acked alternative
            // would hang the caller's bounded flush wait indefinitely.
            if let Some(ref flush_rx) = self.flush_rx {
                while let Ok(ack_tx) = flush_rx.try_recv() {
                    while let Ok(update) = self.rx.try_recv() {
                        Self::apply(&mut writer, &update);
                        pending_count += 1;
                    }
                    if pending_count > 0 {
                        if self.commit_now(&mut writer, &mut consecutive_failures, &mut backoff) {
                            pending_count = 0;
                            last_commit = Instant::now();
                            retry_not_before = None;
                        } else {
                            // Arm the fast retry cadence, exactly as the batch and
                            // timeout paths do. Without this the retained batch
                            // would only be re-attempted on the next time-based
                            // commit (>= commit_every seconds away), delaying
                            // recovery from a transient fault by seconds instead
                            // of one backoff interval.
                            retry_not_before = Some(Instant::now() + backoff);
                        }
                    }
                    let _ = ack_tx.send(());
                }
            }

            // ── Pending retry after a failed commit ─────────────────────────
            // If a previous commit failed and the backoff has elapsed, retry it
            // before blocking on recv so we recover promptly once the underlying
            // fault (disk full, fsync error) clears.
            if retry_not_before.is_some_and(|d| Instant::now() >= d) && pending_count > 0 {
                if self.commit_now(&mut writer, &mut consecutive_failures, &mut backoff) {
                    pending_count = 0;
                    last_commit = Instant::now();
                    retry_not_before = None;
                } else {
                    retry_not_before = Some(Instant::now() + backoff);
                }
            }

            // Poll at the smaller of the standard tick and the outstanding
            // backoff so a pending retry is not delayed past its deadline.
            let poll = retry_not_before.map_or(Duration::from_millis(100), |d| {
                d.saturating_duration_since(Instant::now())
                    .min(Duration::from_millis(100))
            });

            match self.rx.recv_timeout(poll) {
                Ok(update) => {
                    Self::apply(&mut writer, &update);
                    pending_count += 1;

                    // Commit if batch is full (and not in a backoff window).
                    if pending_count >= self.commit_every_n && retry_not_before.is_none() {
                        if self.commit_now(&mut writer, &mut consecutive_failures, &mut backoff) {
                            pending_count = 0;
                            last_commit = Instant::now();
                        } else {
                            retry_not_before = Some(Instant::now() + backoff);
                        }
                    }
                }
                Err(RecvTimeoutError::Timeout) => {
                    // Time-based commit: flush if we have pending docs and enough
                    // wall-clock time has elapsed (and not mid-backoff).
                    if pending_count > 0
                        && retry_not_before.is_none()
                        && last_commit.elapsed() >= self.commit_every
                    {
                        if self.commit_now(&mut writer, &mut consecutive_failures, &mut backoff) {
                            pending_count = 0;
                            last_commit = Instant::now();
                        } else {
                            retry_not_before = Some(Instant::now() + backoff);
                        }
                    }
                }
                Err(RecvTimeoutError::Disconnected) => {
                    // Channel closed -- flush remaining and exit. Retry the final
                    // commit a bounded number of times so a transient fault at
                    // shutdown does not silently drop the tail of the stream.
                    if pending_count > 0 {
                        return self.final_flush(&mut writer);
                    }
                    break;
                }
            }
        }

        Ok(())
    }

    /// Apply a single pending write to the in-memory writer batch.
    ///
    /// `index_item` can only fail if the Tantivy writer thread has already
    /// crashed (`add_document` returns an error). That is itself a commit-class
    /// failure that the subsequent `commit()` will surface and retry, so we log
    /// and continue rather than propagating — dropping the loop here is exactly
    /// the failure mode this redesign eliminates.
    fn apply(writer: &mut crate::text::writer::TextIndexWriter<'_>, update: &PendingWrite) {
        if update.deleted {
            writer.delete_item(update.entity_id);
        } else if let Err(e) = writer.index_item(update.entity_id, &update.metadata) {
            tracing::error!(
                error = %e,
                entity_id = %update.entity_id,
                "text syncer: index_item failed; document deferred to next commit retry"
            );
        }
    }

    /// Attempt a commit, updating the failure counter, backoff, and health flag.
    ///
    /// Returns `true` on success (and resets the failure state), `false` on
    /// failure (and leaves the batch buffered for retry). Never propagates the
    /// error — the loop must keep running.
    fn commit_now(
        &self,
        writer: &mut crate::text::writer::TextIndexWriter<'_>,
        consecutive_failures: &mut u32,
        backoff: &mut Duration,
    ) -> bool {
        match writer.commit() {
            Ok(()) => {
                if *consecutive_failures > 0 {
                    tracing::info!(
                        "text syncer: commit recovered after {} consecutive failure(s)",
                        *consecutive_failures
                    );
                }
                *consecutive_failures = 0;
                *backoff = COMMIT_RETRY_BACKOFF_INITIAL;
                // Release: pairs with TextIndex::is_healthy's Acquire so a reader
                // that sees `true` also sees the committed state.
                self.healthy.store(true, Ordering::Release);
                true
            }
            Err(e) => {
                *consecutive_failures = consecutive_failures.saturating_add(1);
                tracing::error!(
                    error = %e,
                    consecutive_failures = *consecutive_failures,
                    backoff_ms = backoff.as_millis(),
                    "text syncer: commit failed; retaining batch and retrying after backoff"
                );
                if *consecutive_failures >= COMMIT_FAILURES_BEFORE_UNHEALTHY {
                    // Release: pairs with TextIndex::is_healthy's Acquire so a
                    // reader observing `false` also observes the writes the
                    // syncer made before it began failing.
                    self.healthy.store(false, Ordering::Release);
                }
                *backoff = (*backoff * 2).min(COMMIT_RETRY_BACKOFF_MAX);
                false
            }
        }
    }

    /// Final flush on graceful shutdown, retried a bounded number of times.
    ///
    /// Unlike the steady-state loop (which can retry forever because the process
    /// is still alive), shutdown must terminate. We retry a few times with the
    /// standard backoff; if the commit still fails we mark the index unhealthy
    /// and return the error so the joining thread logs it.
    fn final_flush(
        &self,
        writer: &mut crate::text::writer::TextIndexWriter<'_>,
    ) -> crate::Result<()> {
        let mut backoff = COMMIT_RETRY_BACKOFF_INITIAL;
        let mut last_err = None;
        for attempt in 0..COMMIT_FAILURES_BEFORE_UNHEALTHY {
            match writer.commit() {
                Ok(()) => {
                    self.healthy.store(true, Ordering::Release);
                    return Ok(());
                }
                Err(e) => {
                    tracing::error!(
                        error = %e,
                        attempt,
                        "text syncer: shutdown flush failed"
                    );
                    last_err = Some(e);
                    // Sleep between attempts to let a transient fault clear, but
                    // skip the sleep after the final attempt: we are about to give
                    // up and return, so the trailing backoff would only delay
                    // shutdown without ever retrying again.
                    if attempt + 1 < COMMIT_FAILURES_BEFORE_UNHEALTHY {
                        std::thread::sleep(backoff);
                        backoff = (backoff * 2).min(COMMIT_RETRY_BACKOFF_MAX);
                    }
                }
            }
        }
        self.healthy.store(false, Ordering::Release);
        Err(last_err.unwrap_or_else(|| {
            crate::TidalError::internal("text_syncer_shutdown", "final flush failed".to_owned())
        }))
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::schema::{TextFieldDef, TextFieldType};

    fn test_fields() -> Vec<TextFieldDef> {
        vec![TextFieldDef {
            key: "title".to_owned(),
            field_type: TextFieldType::Text,
        }]
    }

    fn make_write(id: u64, title: &str) -> PendingWrite {
        let mut metadata = HashMap::new();
        metadata.insert("title".to_owned(), title.to_owned());
        PendingWrite {
            entity_id: EntityId::new(id),
            metadata,
            deleted: false,
        }
    }

    #[test]
    fn syncer_commits_on_batch() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (tx, rx) = crossbeam::channel::unbounded();

        let idx_clone = Arc::clone(&idx);
        let handle = std::thread::Builder::new()
            .name("test-syncer-batch".into())
            .spawn(move || TextIndexSyncer::new(idx_clone, rx, 3, 60).run())
            .unwrap();

        // Send exactly commit_every_n items.
        tx.send(make_write(1, "alpha")).unwrap();
        tx.send(make_write(2, "beta")).unwrap();
        tx.send(make_write(3, "gamma")).unwrap();

        // Drop sender to signal shutdown.
        drop(tx);
        handle.join().unwrap().unwrap();

        // After join, all docs should be committed.
        idx.reader.reload().unwrap();
        assert_eq!(idx.reader.searcher().num_docs(), 3);
    }

    #[test]
    fn syncer_commits_on_timeout() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (tx, rx) = crossbeam::channel::unbounded();

        let idx_clone = Arc::clone(&idx);
        let handle = std::thread::Builder::new()
            .name("test-syncer-timeout".into())
            .spawn(move || TextIndexSyncer::new(idx_clone, rx, 100, 1).run())
            .unwrap();

        // Send 1 item (below commit_every_n=100).
        tx.send(make_write(1, "alpha")).unwrap();

        // Wait for time-based commit to fire (commit_every=1s, poll=100ms).
        std::thread::sleep(Duration::from_millis(1500));

        // Reload and check.
        idx.reader.reload().unwrap();
        assert_eq!(idx.reader.searcher().num_docs(), 1);

        // Shut down cleanly.
        drop(tx);
        handle.join().unwrap().unwrap();
    }

    #[test]
    fn syncer_flushes_on_shutdown() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (tx, rx) = crossbeam::channel::unbounded();

        let idx_clone = Arc::clone(&idx);
        let handle = std::thread::Builder::new()
            .name("test-syncer-flush".into())
            .spawn(move || TextIndexSyncer::new(idx_clone, rx, 100, 60).run())
            .unwrap();

        // Send 2 items (below both thresholds).
        tx.send(make_write(1, "alpha")).unwrap();
        tx.send(make_write(2, "beta")).unwrap();

        // Drop sender immediately -- the syncer should flush before exiting.
        drop(tx);
        handle.join().unwrap().unwrap();

        idx.reader.reload().unwrap();
        assert_eq!(idx.reader.searcher().num_docs(), 2);
    }

    #[test]
    fn syncer_handles_delete() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (tx, rx) = crossbeam::channel::unbounded();

        let idx_clone = Arc::clone(&idx);
        let handle = std::thread::Builder::new()
            .name("test-syncer-delete".into())
            .spawn(move || TextIndexSyncer::new(idx_clone, rx, 100, 60).run())
            .unwrap();

        // Write 3 items, then delete entity 1.
        tx.send(make_write(1, "alpha")).unwrap();
        tx.send(make_write(2, "beta")).unwrap();
        tx.send(make_write(3, "gamma")).unwrap();
        tx.send(PendingWrite {
            entity_id: EntityId::new(1),
            metadata: HashMap::new(),
            deleted: true,
        })
        .unwrap();

        drop(tx);
        handle.join().unwrap().unwrap();

        idx.reader.reload().unwrap();
        assert_eq!(idx.reader.searcher().num_docs(), 2);
    }

    #[test]
    fn rebuild_from_indexes_all_items() {
        let idx = TextIndex::ephemeral(&test_fields()).unwrap();

        let mut m1 = HashMap::new();
        m1.insert("title".to_owned(), "Jazz Piano".to_owned());

        let mut m2 = HashMap::new();
        m2.insert("title".to_owned(), "Rock Guitar".to_owned());

        let items = vec![(EntityId::new(1), m1), (EntityId::new(2), m2)];

        idx.rebuild_from(items.into_iter()).unwrap();

        idx.reader.reload().unwrap();
        assert_eq!(idx.reader.searcher().num_docs(), 2);
    }

    /// Poll `f` until it returns `true` or `timeout` elapses. Returns whether it
    /// succeeded. Used to wait for the syncer's background commits/retries
    /// without sleeping for a fixed (and racy) duration.
    fn wait_until(timeout: Duration, mut f: impl FnMut() -> bool) -> bool {
        let deadline = Instant::now() + timeout;
        while Instant::now() < deadline {
            if f() {
                return true;
            }
            std::thread::sleep(Duration::from_millis(10));
        }
        f()
    }

    /// Count of alive docs after reloading the reader.
    fn live_docs(idx: &TextIndex) -> u64 {
        idx.reader.reload().unwrap();
        idx.reader.searcher().num_docs()
    }

    /// CRITICAL regression: a single transient commit failure must NOT kill the
    /// syncer. After the injected failure the batch is retained, retried, and a
    /// subsequent write still lands. The index stays healthy (one failure is
    /// below the unhealthy threshold).
    #[test]
    fn syncer_survives_transient_commit_failure_and_recovers() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (tx, rx) = crossbeam::channel::unbounded();

        let idx_clone = Arc::clone(&idx);
        let handle = std::thread::Builder::new()
            .name("test-syncer-fault".into())
            .spawn(move || {
                // Arm the hook INSIDE the syncer thread: the thread-local must be
                // set on the same thread that runs commit().
                crate::text::writer::force_commit_failures(1);
                TextIndexSyncer::new(idx_clone, rx, 3, 60).run()
            })
            .unwrap();

        // First batch: the batch commit fails once, then the syncer retries.
        tx.send(make_write(1, "alpha")).unwrap();
        tx.send(make_write(2, "beta")).unwrap();
        tx.send(make_write(3, "gamma")).unwrap();

        // The first batch must eventually land despite the injected failure.
        // (Use >= to avoid a race where the retry coalesces with later writes.)
        assert!(
            wait_until(Duration::from_secs(2), || live_docs(&idx) >= 3),
            "first batch should commit after retry; syncer must not have died"
        );

        // The syncer survived: a subsequent batch still lands. Sent only after
        // the first batch is durable so the retry cannot coalesce the two.
        tx.send(make_write(4, "delta")).unwrap();
        tx.send(make_write(5, "epsilon")).unwrap();
        tx.send(make_write(6, "zeta")).unwrap();
        assert!(
            wait_until(Duration::from_secs(2), || live_docs(&idx) == 6),
            "post-recovery batch must land, proving the syncer is still running"
        );

        // One transient failure is below the unhealthy threshold.
        assert!(
            idx.is_healthy(),
            "index should remain healthy after 1 failure"
        );

        drop(tx);
        handle.join().unwrap().unwrap();
    }

    /// After enough consecutive commit failures the health flag flips to
    /// `false`, then flips back to `true` once a commit succeeds. The syncer
    /// keeps draining throughout.
    #[test]
    fn syncer_marks_unhealthy_then_recovers() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (tx, rx) = crossbeam::channel::unbounded();

        let idx_clone = Arc::clone(&idx);
        let handle = std::thread::Builder::new()
            .name("test-syncer-unhealthy".into())
            .spawn(move || {
                // Fail the first COMMIT_FAILURES_BEFORE_UNHEALTHY commit attempts.
                crate::text::writer::force_commit_failures(COMMIT_FAILURES_BEFORE_UNHEALTHY);
                TextIndexSyncer::new(idx_clone, rx, 1, 60).run()
            })
            .unwrap();

        // commit_every_n = 1 so each write triggers a commit attempt.
        tx.send(make_write(1, "alpha")).unwrap();

        // After the threshold of consecutive failures, the index is unhealthy.
        assert!(
            wait_until(Duration::from_secs(3), || !idx.is_healthy()),
            "index should be marked unhealthy after consecutive commit failures"
        );

        // Once the injected failures are exhausted, the retained batch commits
        // and health recovers.
        assert!(
            wait_until(Duration::from_secs(3), || idx.is_healthy()),
            "index should recover to healthy once a commit succeeds"
        );
        assert!(
            wait_until(Duration::from_secs(2), || live_docs(&idx) == 1),
            "the retained write must eventually land"
        );

        drop(tx);
        handle.join().unwrap().unwrap();
    }

    /// Regression: a failed flush-path commit must arm the fast retry cadence
    /// (`retry_not_before`), so the retained batch lands within one backoff
    /// interval — NOT only on the much-slower time-based commit. With a 60s
    /// `commit_every`, the absence of the arm would push recovery 60 seconds out;
    /// here the batch must become durable far inside that window.
    #[test]
    fn flush_path_failure_arms_fast_retry() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (tx, rx) = crossbeam::channel::unbounded();
        let (flush_tx, flush_rx) = crossbeam::channel::unbounded();

        let idx_clone = Arc::clone(&idx);
        let handle = std::thread::Builder::new()
            .name("test-syncer-flush-retry".into())
            .spawn(move || {
                // Fail exactly the flush-path commit; the armed retry then succeeds.
                crate::text::writer::force_commit_failures(1);
                // commit_every_n=100 and commit_every=60s: neither batch-size nor
                // time-based commit can fire, so ONLY the flush + its armed retry
                // can make the batch durable. If the retry is not armed, the
                // assertion below times out.
                TextIndexSyncer::new(idx_clone, rx, 100, 60)
                    .with_flush_rx(flush_rx)
                    .run()
            })
            .unwrap();

        tx.send(make_write(1, "alpha")).unwrap();
        tx.send(make_write(2, "beta")).unwrap();

        // Request a synchronous flush. The flush-path commit fails once; the ack
        // still returns (commit failure does not drop the ack), but the batch is
        // retained and the fast retry is armed.
        let (ack_tx, ack_rx) = crossbeam::channel::bounded(1);
        flush_tx.send(ack_tx).unwrap();
        ack_rx
            .recv_timeout(Duration::from_secs(2))
            .expect("flush ack must return even when the flush-path commit fails");

        // The retained batch must land via the armed fast retry, well inside the
        // 60s time-based window that would otherwise be the only recovery path.
        assert!(
            wait_until(Duration::from_secs(3), || live_docs(&idx) == 2),
            "flush-path failure must arm retry_not_before so the batch lands on \
             the fast cadence, not 60s later on the time-based commit"
        );

        drop(tx);
        drop(flush_tx);
        handle.join().unwrap().unwrap();
    }

    /// Regression: `final_flush` must NOT sleep after the LAST attempt's failure.
    /// With all attempts forced to fail, the only sleeps are between attempts
    /// (100ms + 200ms = 300ms); a trailing sleep after the final attempt would
    /// add another 400ms. Asserting the shutdown completes under 600ms catches
    /// the regression while leaving generous slack for scheduler jitter.
    #[test]
    fn final_flush_skips_trailing_sleep_on_last_attempt() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (tx, rx) = crossbeam::channel::unbounded();

        let idx_clone = Arc::clone(&idx);
        let handle = std::thread::Builder::new()
            .name("test-syncer-final-flush".into())
            .spawn(move || {
                // Fail every shutdown-flush attempt so final_flush exhausts its
                // budget and returns Err — exercising the final iteration.
                crate::text::writer::force_commit_failures(COMMIT_FAILURES_BEFORE_UNHEALTHY);
                TextIndexSyncer::new(idx_clone, rx, 100, 60).run()
            })
            .unwrap();

        // One pending write so the Disconnected branch routes into final_flush.
        tx.send(make_write(1, "alpha")).unwrap();
        drop(tx);

        let start = Instant::now();
        let result = handle.join().unwrap();
        let elapsed = start.elapsed();

        // The final flush gives up and surfaces the error to the joining thread.
        assert!(
            result.is_err(),
            "final_flush should return Err after exhausting retries"
        );
        // Inter-attempt sleeps total 300ms; a trailing sleep would push this to
        // ~700ms. The upper bound is below that, above the 300ms real cost.
        assert!(
            elapsed < Duration::from_millis(600),
            "final_flush must skip the trailing sleep on the last failed attempt; \
             took {elapsed:?} (expected < 600ms)"
        );
    }

    /// A fresh index reports healthy before any commit.
    #[test]
    fn fresh_index_is_healthy() {
        let idx = TextIndex::ephemeral(&test_fields()).unwrap();
        assert!(idx.is_healthy());
        idx.close().unwrap();
    }

    /// WARNING regression: a zero document threshold is clamped to 1 rather than
    /// committing on every write or never committing.
    #[test]
    fn zero_commit_every_n_is_clamped() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (_tx, rx) = crossbeam::channel::unbounded();
        let syncer = TextIndexSyncer::new(Arc::clone(&idx), rx, 0, 60);
        assert_eq!(syncer.commit_every_n, 1, "zero N must clamp to 1");
    }

    /// WARNING regression: a zero-second time threshold is clamped to 1 second
    /// so the timeout path does not spin committing empty/idle batches.
    #[test]
    fn zero_commit_every_secs_is_clamped() {
        let idx = Arc::new(TextIndex::ephemeral(&test_fields()).unwrap());
        let (_tx, rx) = crossbeam::channel::unbounded();
        let syncer = TextIndexSyncer::new(Arc::clone(&idx), rx, 100, 0);
        assert_eq!(
            syncer.commit_every,
            Duration::from_secs(1),
            "zero seconds must clamp to 1s"
        );
    }
}
