//! WAL segment compaction.
//!
//! Deletes WAL segments that are fully covered by a checkpoint.
//! This prevents unbounded WAL growth after checkpoints succeed.

use std::path::Path;

use super::error::WalError;
use super::segment;

/// Result of a compaction operation.
#[derive(Debug)]
pub struct CompactionResult {
    /// Number of WAL segments deleted.
    pub segments_deleted: usize,
    /// Total bytes reclaimed (sum of deleted segment file sizes).
    pub bytes_reclaimed: u64,
    /// Remaining segment count after compaction.
    pub segments_remaining: usize,
}

/// Delete WAL segments that are fully covered by the checkpoint at `checkpoint_seq`.
///
/// A segment with `first_seq < checkpoint_seq` is safe to delete because all its
/// events have been materialized to the signal ledger checkpoint. A segment with
/// `first_seq == checkpoint_seq` may contain events both before and after the
/// checkpoint; it is NOT deleted (conservative: we replay a few extra events
/// rather than risk losing uncovered events).
///
/// # Safety invariant
///
/// The caller must ensure the checkpoint at `checkpoint_seq` has been durably
/// committed to storage BEFORE calling this function. The order is:
///
/// 1. `ledger.checkpoint(storage, meta)` -- durable
/// 2. `storage.flush()` -- durable
/// 3. `compact_wal(wal_dir, meta.wal_sequence)` -- safe to lose old segments
///
/// If we crash between steps 1 and 3, old segments survive and are replayed
/// redundantly on next open. This is correct (idempotent).
///
/// If we crash during step 3 (partial deletion), some segments are deleted
/// and others are not. This is also correct: deleted segments are covered
/// by the checkpoint, surviving segments are replayed.
///
/// # Errors
///
/// Returns `WalError::Io` on filesystem failure. Partial deletion may occur
/// if an error is encountered mid-way -- this is safe (see invariant above).
pub fn compact_wal(wal_dir: &Path, checkpoint_seq: u64) -> Result<CompactionResult, WalError> {
    let segments = segment::list_segments(wal_dir)?;
    let total_before = segments.len();

    let mut deleted = 0usize;
    let mut bytes_reclaimed = 0u64;

    for (seg_first_seq, seg_path) in &segments {
        // Only delete segments whose first_seq is strictly less than the
        // checkpoint sequence. Segments starting at or after checkpoint_seq
        // may contain events that are not yet covered by the checkpoint.
        if *seg_first_seq < checkpoint_seq {
            // SAFETY: TOCTOU gap between metadata() and remove_file() is benign.
            // `list_segments()` only returns files that exist on disk at scan time.
            // If another process deleted this segment between the scan and now,
            // remove_file() returns an error which compact_wal propagates -- but
            // TidalDb enforces single-process-per-directory ownership (see Config::lock_dir),
            // so this cannot happen in production. The gap is a safe no-op under the
            // single-owner invariant.
            let file_size = std::fs::metadata(seg_path).map(|m| m.len()).unwrap_or(0);

            std::fs::remove_file(seg_path)?;
            deleted += 1;
            bytes_reclaimed += file_size;

            tracing::debug!(
                segment_first_seq = seg_first_seq,
                file_size,
                "compacted WAL segment"
            );
        }
    }

    // Fsync the directory to ensure the unlink operations are durable.
    // Without this, a crash after deletion but before directory metadata
    // flush could "resurrect" deleted segment files.
    if deleted > 0 {
        let dir_fd = std::fs::File::open(wal_dir)?;
        dir_fd.sync_all()?;
    }

    let remaining = total_before - deleted;

    tracing::info!(
        deleted,
        bytes_reclaimed,
        remaining,
        checkpoint_seq,
        "WAL compaction complete"
    );

    Ok(CompactionResult {
        segments_deleted: deleted,
        bytes_reclaimed,
        segments_remaining: remaining,
    })
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::replication::ShardId;
    use crate::wal::segment::{SegmentWriter, list_segments, segment_filename};
    use std::fs;

    #[test]
    fn compact_empty_dir() {
        let dir = tempfile::tempdir().unwrap();
        let result = compact_wal(dir.path(), 100).unwrap();
        assert_eq!(result.segments_deleted, 0);
        assert_eq!(result.segments_remaining, 0);
    }

    #[test]
    fn compact_deletes_old_segments() {
        let dir = tempfile::tempdir().unwrap();
        // Create segments at seq 1, 50, 100, 200.
        for &seq in &[1u64, 50, 100, 200] {
            let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap();
        }
        assert_eq!(list_segments(dir.path()).unwrap().len(), 4);

        // Compact with checkpoint at seq=100.
        // Segments 1 and 50 have first_seq < 100, so they are deleted.
        // Segments 100 and 200 are preserved.
        let result = compact_wal(dir.path(), 100).unwrap();
        assert_eq!(result.segments_deleted, 2);
        assert_eq!(result.segments_remaining, 2);

        let remaining = list_segments(dir.path()).unwrap();
        assert_eq!(remaining[0].0, 100);
        assert_eq!(remaining[1].0, 200);
    }

    #[test]
    fn compact_preserves_current_segment() {
        let dir = tempfile::tempdir().unwrap();
        let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 100, 1024).unwrap();

        // Checkpoint at seq=100: segment starting at 100 is NOT deleted
        // (it may contain events >= 100).
        let result = compact_wal(dir.path(), 100).unwrap();
        assert_eq!(result.segments_deleted, 0);
        assert_eq!(result.segments_remaining, 1);
    }

    #[test]
    fn compact_no_segments_to_delete() {
        let dir = tempfile::tempdir().unwrap();
        let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 500, 1024).unwrap();

        let result = compact_wal(dir.path(), 100).unwrap();
        assert_eq!(result.segments_deleted, 0);
        assert_eq!(result.segments_remaining, 1);
    }

    #[test]
    fn compact_all_segments_old() {
        let dir = tempfile::tempdir().unwrap();
        for &seq in &[1u64, 10, 20] {
            let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap();
        }

        let result = compact_wal(dir.path(), 1000).unwrap();
        assert_eq!(result.segments_deleted, 3);
        assert_eq!(result.segments_remaining, 0);
    }

    #[test]
    fn compact_idempotent() {
        let dir = tempfile::tempdir().unwrap();
        let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 1024).unwrap();
        let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 100, 1024).unwrap();

        compact_wal(dir.path(), 100).unwrap();
        // Running compaction again should be a no-op.
        let result = compact_wal(dir.path(), 100).unwrap();
        assert_eq!(result.segments_deleted, 0);
        assert_eq!(result.segments_remaining, 1);
    }

    #[test]
    fn compact_crash_during_deletion_is_safe() {
        let dir = tempfile::tempdir().unwrap();
        for &seq in &[1u64, 50, 100, 200] {
            let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap();
        }

        // Simulate a crash mid-compaction: segment 1 was deleted but segment 50 wasn't.
        // On restart, compact_wal is called again with the same checkpoint_seq.
        let seg1_path = dir.path().join(segment_filename(ShardId::SINGLE, 1));
        fs::remove_file(&seg1_path).unwrap();

        // Compaction should succeed: seg 50 is deleted, segs 100 and 200 survive.
        let result = compact_wal(dir.path(), 100).unwrap();
        assert_eq!(result.segments_deleted, 1); // only seg 50 deleted (seg 1 already gone)
        assert_eq!(result.segments_remaining, 2);

        let remaining = list_segments(dir.path()).unwrap();
        assert_eq!(remaining[0].0, 100);
        assert_eq!(remaining[1].0, 200);
    }

    // Keep the old SegmentWriter drop behavior from causing test issues.
    // SegmentWriter drops may write empty segment files. Verify compaction
    // still works correctly.
    #[test]
    fn compact_handles_empty_segment_files() {
        let dir = tempfile::tempdir().unwrap();
        // Write a segment file directly (bypassing SegmentWriter to avoid I/O).
        let seg1_path = dir.path().join(segment_filename(ShardId::SINGLE, 1));
        fs::write(&seg1_path, []).unwrap();
        let seg2_path = dir.path().join(segment_filename(ShardId::SINGLE, 100));
        fs::write(&seg2_path, []).unwrap();

        let result = compact_wal(dir.path(), 100).unwrap();
        assert_eq!(result.segments_deleted, 1); // seq=1 < 100
        assert_eq!(result.segments_remaining, 1); // seq=100 preserved
    }
}
