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

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

use crate::schema::EntityId;
use crate::text::index::TextIndex;

/// 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>,
    /// WAL sequence number at the time of the write. Used by commit payloads
    /// so the text index can track how far it has caught up.
    pub seq: u64,
    /// 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<()>>>,
}

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.
    pub const fn new(
        index: Arc<TextIndex>,
        rx: Receiver<PendingWrite>,
        commit_every_n: usize,
        commit_every_secs: u64,
    ) -> Self {
        Self {
            index,
            rx,
            commit_every_n,
            commit_every: Duration::from_secs(commit_every_secs),
            flush_rx: None,
        }
    }

    /// 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.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if the writer lock is poisoned or if a
    /// Tantivy commit fails.
    #[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_seq: u64 = 0;
        let mut last_commit = Instant::now();

        loop {
            // Check for flush requests (non-blocking). When a caller sends a
            // one-shot Sender<()>, we drain all pending writes from the channel,
            // index them, commit, then acknowledge. This ensures the caller sees
            // all writes that preceded the flush request.
            if let Some(ref flush_rx) = self.flush_rx {
                while let Ok(ack_tx) = flush_rx.try_recv() {
                    // Drain all pending writes from the channel before committing.
                    while let Ok(update) = self.rx.try_recv() {
                        if update.deleted {
                            writer.delete_item(update.entity_id);
                        } else {
                            writer.index_item(update.entity_id, &update.metadata)?;
                        }
                        last_seq = update.seq;
                        pending_count += 1;
                    }
                    if pending_count > 0 {
                        writer.commit(last_seq)?;
                        pending_count = 0;
                        last_commit = Instant::now();
                    }
                    let _ = ack_tx.send(());
                }
            }

            match self.rx.recv_timeout(Duration::from_millis(100)) {
                Ok(update) => {
                    if update.deleted {
                        writer.delete_item(update.entity_id);
                    } else {
                        writer.index_item(update.entity_id, &update.metadata)?;
                    }
                    last_seq = update.seq;
                    pending_count += 1;

                    // Commit if batch is full.
                    if pending_count >= self.commit_every_n {
                        writer.commit(last_seq)?;
                        pending_count = 0;
                        last_commit = Instant::now();
                    }
                }
                Err(RecvTimeoutError::Timeout) => {
                    // Time-based commit: flush if we have pending docs and
                    // enough wall-clock time has elapsed.
                    if pending_count > 0 && last_commit.elapsed() >= self.commit_every {
                        writer.commit(last_seq)?;
                        pending_count = 0;
                        last_commit = Instant::now();
                    }
                }
                Err(RecvTimeoutError::Disconnected) => {
                    // Channel closed -- flush remaining and exit.
                    if pending_count > 0 {
                        writer.commit(last_seq)?;
                    }
                    break;
                }
            }
        }

        Ok(())
    }
}

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

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

    fn make_write(id: u64, title: &str, seq: u64) -> PendingWrite {
        let mut metadata = HashMap::new();
        metadata.insert("title".to_owned(), title.to_owned());
        PendingWrite {
            entity_id: EntityId::new(id),
            metadata,
            seq,
            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", 1)).unwrap();
        tx.send(make_write(2, "beta", 2)).unwrap();
        tx.send(make_write(3, "gamma", 3)).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);

        // Verify sequence number was stored.
        assert_eq!(TextIndexWriter::last_committed_seq(&idx.index), 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", 1)).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", 1)).unwrap();
        tx.send(make_write(2, "beta", 2)).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);
        assert_eq!(TextIndexWriter::last_committed_seq(&idx.index), 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", 1)).unwrap();
        tx.send(make_write(2, "beta", 2)).unwrap();
        tx.send(make_write(3, "gamma", 3)).unwrap();
        tx.send(PendingWrite {
            entity_id: EntityId::new(1),
            metadata: HashMap::new(),
            seq: 4,
            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(), 5).unwrap();

        idx.reader.reload().unwrap();
        assert_eq!(idx.reader.searcher().num_docs(), 2);
        assert_eq!(TextIndexWriter::last_committed_seq(&idx.index), 5);
    }
}
