//! Group commit buffer for batching fsync operations.
//!
//! The `GroupCommitBuffer` wraps a `Journal` and batches writes so that
//! multiple concurrent appenders share a single fsync. This dramatically
//! reduces fsync overhead under concurrent load.
//!
//! # Architecture
//!
//! Writers send payloads through an MPSC channel. A background flusher task
//! collects up to `max_writes` payloads (or waits up to `max_duration`),
//! writes them all to the Journal with `DurabilityLevel::Eventual`, calls
//! `force_sync()` once, then responds to all waiting writers.
//!
//! # Feature Gate
//!
//! This module is only available with the `group-commit` feature enabled,
//! which brings in the `tokio` dependency.

use crate::error::QuarantineError;
use crate::journal::Journal;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, oneshot, Notify};
use tracing::{debug, error, info, instrument, warn};

/// Type alias for a flush batch entry: response sender + write result.
type FlushEntry = (oneshot::Sender<Result<u64, QuarantineError>>, Result<u64, QuarantineError>);

/// Configuration for the group commit buffer.
#[derive(Debug, Clone)]
pub struct GroupCommitConfig {
    /// Maximum number of writes to batch before flushing.
    pub max_writes: usize,
    /// Maximum time to wait before flushing a partial batch.
    pub max_duration: Duration,
    /// Channel capacity for pending write requests.
    pub channel_capacity: usize,
}

impl Default for GroupCommitConfig {
    fn default() -> Self {
        Self { max_writes: 100, max_duration: Duration::from_millis(10), channel_capacity: 10_000 }
    }
}

/// A write request sent through the channel.
struct WriteRequest {
    payload: Vec<u8>,
    response: oneshot::Sender<Result<u64, QuarantineError>>,
}

/// Group commit buffer that batches fsync operations.
///
/// Owns the Journal internally and provides an async `append()` API.
/// Concurrent writers are coalesced into batches that share a single fsync.
///
/// This struct is cheaply cloneable (just clones the channel sender).
#[derive(Clone)]
pub struct GroupCommitBuffer {
    sender: mpsc::Sender<WriteRequest>,
}

/// Builder for constructing a GroupCommitBuffer with optional flush notifications.
pub struct GroupCommitBufferBuilder {
    journal: Journal,
    config: GroupCommitConfig,
    flush_notify: Option<Arc<Notify>>,
}

impl GroupCommitBufferBuilder {
    /// Create a new builder with the given journal and config.
    pub fn new(journal: Journal, config: GroupCommitConfig) -> Self {
        Self { journal, config, flush_notify: None }
    }

    /// Set an optional notification channel to signal after each flush.
    ///
    /// When set, the flusher will call `notify.notify_one()` after each
    /// successful batch flush. This allows downstream consumers (like
    /// IngestWorker) to react immediately to new data instead of polling.
    pub fn with_flush_notify(mut self, notify: Arc<Notify>) -> Self {
        self.flush_notify = Some(notify);
        self
    }

    /// Build the GroupCommitBuffer, spawning the background flusher task.
    #[instrument(skip(self), fields(max_writes = self.config.max_writes, max_duration_ms = self.config.max_duration.as_millis() as u64))]
    pub fn build(self) -> GroupCommitBuffer {
        let (sender, receiver) = mpsc::channel(self.config.channel_capacity);

        tokio::spawn(GroupCommitBuffer::flusher_loop(
            self.journal,
            receiver,
            self.config,
            self.flush_notify,
        ));

        info!("GroupCommitBuffer started");
        GroupCommitBuffer { sender }
    }
}

impl GroupCommitBuffer {
    /// Create a new group commit buffer wrapping the given journal.
    ///
    /// Spawns a background flusher task on the current tokio runtime.
    /// The journal is moved into the flusher and is not accessible externally.
    ///
    /// For more control (e.g., flush notifications), use `GroupCommitBufferBuilder`.
    #[instrument(skip(journal), fields(max_writes = config.max_writes, max_duration_ms = config.max_duration.as_millis() as u64))]
    pub fn new(journal: Journal, config: GroupCommitConfig) -> Self {
        GroupCommitBufferBuilder::new(journal, config).build()
    }

    /// Create a builder for more advanced configuration.
    pub fn builder(journal: Journal, config: GroupCommitConfig) -> GroupCommitBufferBuilder {
        GroupCommitBufferBuilder::new(journal, config)
    }

    /// Append a payload to the journal via the group commit buffer.
    ///
    /// Returns the WAL offset of the written record once the batch
    /// containing this write has been fsynced.
    pub async fn append(&self, payload: Vec<u8>) -> Result<u64, QuarantineError> {
        let (response_tx, response_rx) = oneshot::channel();

        let request = WriteRequest { payload, response: response_tx };

        self.sender.send(request).await.map_err(|_| {
            QuarantineError::IoGeneric(std::io::Error::other(
                "GroupCommitBuffer flusher has shut down",
            ))
        })?;

        response_rx.await.map_err(|_| {
            QuarantineError::IoGeneric(std::io::Error::other(
                "GroupCommitBuffer flusher dropped response channel",
            ))
        })?
    }

    /// Background flusher loop.
    ///
    /// Collects writes into batches, writes them all, then fsyncs once.
    /// If `flush_notify` is provided, signals it after each successful flush.
    async fn flusher_loop(
        mut journal: Journal,
        mut receiver: mpsc::Receiver<WriteRequest>,
        config: GroupCommitConfig,
        flush_notify: Option<Arc<Notify>>,
    ) {
        let mut batch: Vec<WriteRequest> = Vec::with_capacity(config.max_writes);

        loop {
            // Wait for the first write request
            let first = match receiver.recv().await {
                Some(req) => req,
                None => {
                    info!("GroupCommitBuffer channel closed, flusher exiting");
                    return;
                }
            };
            batch.push(first);

            // Collect more requests up to max_writes or max_duration
            let deadline = tokio::time::Instant::now() + config.max_duration;
            while batch.len() < config.max_writes {
                match tokio::time::timeout_at(deadline, receiver.recv()).await {
                    Ok(Some(req)) => batch.push(req),
                    Ok(None) => {
                        // Channel closed, flush what we have and exit
                        Self::flush_batch(&mut journal, &mut batch, flush_notify.as_ref());
                        info!("GroupCommitBuffer channel closed during batch, flusher exiting");
                        return;
                    }
                    Err(_) => break, // Timeout reached, flush
                }
            }

            debug!(batch_size = batch.len(), "Flushing batch");
            Self::flush_batch(&mut journal, &mut batch, flush_notify.as_ref());
        }
    }

    /// Write all requests in the batch, fsync once, respond to all waiters.
    ///
    /// If `flush_notify` is provided and the batch succeeds, signals it
    /// so downstream consumers can react to new data.
    fn flush_batch(
        journal: &mut Journal,
        batch: &mut Vec<WriteRequest>,
        flush_notify: Option<&Arc<Notify>>,
    ) {
        let mut results: Vec<FlushEntry> = Vec::with_capacity(batch.len());

        let mut any_error = false;

        for request in batch.drain(..) {
            if any_error {
                // If a previous write in this batch failed, fail all subsequent
                let err = QuarantineError::IoGeneric(std::io::Error::other(
                    "Previous write in batch failed",
                ));
                results.push((request.response, Err(err)));
                continue;
            }

            match journal.append(request.payload) {
                Ok(offset) => {
                    results.push((request.response, Ok(offset)));
                }
                Err(e) => {
                    error!(error = %e, "Write failed in group commit batch");
                    any_error = true;
                    results.push((request.response, Err(e)));
                }
            }
        }

        // Single fsync for the entire batch
        let flush_succeeded = if !any_error {
            let fsync_start = Instant::now();
            if let Err(e) = journal.force_sync() {
                error!(error = %e, "Fsync failed in group commit batch");
                // Convert all Ok results to errors since fsync failed
                for (_, result) in &mut results {
                    if result.is_ok() {
                        *result = Err(QuarantineError::IoGeneric(std::io::Error::other(
                            "Batch fsync failed",
                        )));
                    }
                }
                false
            } else {
                let fsync_ms = fsync_start.elapsed().as_millis();
                if fsync_ms > 500 {
                    warn!(fsync_ms, batch_size = results.len(), "Slow fsync detected");
                }
                true
            }
        } else {
            false
        };

        // Send all responses
        for (sender, result) in results {
            // Ignore send errors - the receiver may have been dropped (timeout)
            let _ = sender.send(result);
        }

        // Signal downstream consumers that new data is available
        if flush_succeeded {
            if let Some(notify) = flush_notify {
                notify.notify_one();
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::durability::DurabilityLevel;
    use tempfile::tempdir;

    fn create_test_journal() -> (tempfile::TempDir, Journal) {
        let dir = tempdir().expect("tempdir");
        let wal_path = dir.path().join("wal");
        let journal = Journal::open(&wal_path)
            .expect("open journal")
            .with_durability(DurabilityLevel::Eventual);
        (dir, journal)
    }

    #[tokio::test]
    async fn test_single_write_through_buffer() {
        let (_dir, journal) = create_test_journal();
        let config = GroupCommitConfig::default();
        let buffer = GroupCommitBuffer::new(journal, config);

        let offset = buffer.append(b"hello world".to_vec()).await.expect("append");
        assert_eq!(offset, 8); // HEADER_SIZE
    }

    #[tokio::test]
    async fn test_batch_coalesces_fsync() {
        let (_dir, journal) = create_test_journal();
        let config = GroupCommitConfig {
            max_writes: 50,
            max_duration: Duration::from_millis(100),
            channel_capacity: 1000,
        };
        let buffer = GroupCommitBuffer::new(journal, config);

        // Launch 50 concurrent writes
        let mut handles = Vec::new();
        for i in 0..50 {
            let buf = buffer.clone();
            handles.push(tokio::spawn(async move {
                buf.append(format!("record {}", i).into_bytes()).await
            }));
        }

        let mut offsets = Vec::new();
        for handle in handles {
            let offset = handle.await.expect("join").expect("append");
            offsets.push(offset);
        }

        // All offsets should be unique
        offsets.sort();
        offsets.dedup();
        assert_eq!(offsets.len(), 50);
    }

    #[tokio::test]
    async fn test_flush_on_timeout() {
        let (_dir, journal) = create_test_journal();
        let config = GroupCommitConfig {
            max_writes: 1000, // High threshold - won't trigger
            max_duration: Duration::from_millis(50),
            channel_capacity: 100,
        };
        let buffer = GroupCommitBuffer::new(journal, config);

        // Single write should flush after timeout
        let offset = buffer.append(b"timeout test".to_vec()).await.expect("append");
        assert_eq!(offset, 8);
    }

    #[tokio::test]
    async fn test_flush_on_max_writes() {
        let (_dir, journal) = create_test_journal();
        let config = GroupCommitConfig {
            max_writes: 5,
            max_duration: Duration::from_secs(60), // Long timeout - won't trigger
            channel_capacity: 100,
        };
        let buffer = GroupCommitBuffer::new(journal, config);

        // Write exactly max_writes records
        let mut handles = Vec::new();
        for i in 0..5 {
            let buf = buffer.clone();
            handles.push(tokio::spawn(async move {
                buf.append(format!("rec {}", i).into_bytes()).await
            }));
        }

        for handle in handles {
            handle.await.expect("join").expect("append");
        }
    }

    #[tokio::test]
    async fn test_concurrent_writers_unique_offsets() {
        let (_dir, journal) = create_test_journal();
        let config = GroupCommitConfig {
            max_writes: 50,
            max_duration: Duration::from_millis(20),
            channel_capacity: 10_000,
        };
        let buffer = GroupCommitBuffer::new(journal, config);

        // 10 tasks x 100 writes
        let mut handles = Vec::new();
        for task_id in 0..10 {
            for write_id in 0..100 {
                let buf = buffer.clone();
                let payload = format!("task {} write {}", task_id, write_id).into_bytes();
                handles.push(tokio::spawn(async move { buf.append(payload).await }));
            }
        }

        let mut offsets = Vec::new();
        for handle in handles {
            let offset = handle.await.expect("join").expect("append");
            offsets.push(offset);
        }

        // All 1000 offsets must be unique
        offsets.sort();
        let unique_count = offsets.len();
        offsets.dedup();
        assert_eq!(offsets.len(), unique_count, "All offsets should be unique");
        assert_eq!(offsets.len(), 1000);
    }

    #[tokio::test]
    async fn test_error_propagation_to_waiters() {
        // Dropping the buffer (and thus the sender) should cause pending
        // appends to fail
        let (_dir, journal) = create_test_journal();
        let config = GroupCommitConfig::default();
        let buffer = GroupCommitBuffer::new(journal, config);

        // First write should succeed
        buffer.append(b"ok".to_vec()).await.expect("first append");

        // Drop the buffer to close the channel
        drop(buffer);

        // Can't send more since we dropped it - this is correct behavior
    }
}
