//! Backup support for tidalDB.
//!
//! Provides [`TidalDb::create_backup`] which quiesces writes, flushes all
//! durable state, and copies the data directory to a destination path.
//!
//! During the backup window, signal writes return
//! [`TidalError::Backpressure`] so callers know to retry. The window is
//! typically brief: a synchronous flush (~milliseconds) plus the time to
//! copy the data directory (proportional to data size).
//!
//! The backup is **crash-consistent**: it captures a point-in-time snapshot
//! as of the flush, but is not taken under a hot write path. Any signals
//! written after the backup resumes are not included.

use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};

use crate::schema::{DurabilityError, TidalError, Timestamp};

/// Metadata about a completed backup.
#[derive(Debug, Clone)]
pub struct BackupInfo {
    /// The destination path where the backup was written.
    pub path: std::path::PathBuf,
    /// Number of items in the database at backup time.
    pub item_count: u64,
    /// Timestamp when the backup was completed (nanoseconds since epoch).
    pub timestamp_ns: u64,
    /// Total size of the backup in bytes.
    pub size_bytes: u64,
}

/// RAII guard that clears the `backup_in_progress` flag on drop.
///
/// Ensures the flag is always reset even if `create_backup` returns early
/// via `?` or an unexpected panic.
struct BackupGuard<'a>(&'a AtomicBool);

impl Drop for BackupGuard<'_> {
    fn drop(&mut self) {
        // Release: ensures any thread that subsequently loads with Acquire
        // sees that the backup is no longer in progress.
        self.0.store(false, Ordering::Release);
    }
}

/// Recursively copy all files and subdirectories from `src` to `dest`.
///
/// Returns the total number of bytes copied. Skips `tidaldb.lock` (the
/// advisory lock file is process-specific and will be recreated on open).
///
/// # Errors
///
/// Returns `TidalError::Durability` if any filesystem operation fails.
fn copy_dir_recursive(src: &Path, dest: &Path) -> crate::Result<u64> {
    let mut total_bytes: u64 = 0;

    let entries = std::fs::read_dir(src).map_err(|e| {
        TidalError::Durability(DurabilityError {
            message: format!("failed to read directory {}: {e}", src.display()),
        })
    })?;

    for entry in entries {
        let entry = entry.map_err(|e| {
            TidalError::Durability(DurabilityError {
                message: format!("failed to read directory entry in {}: {e}", src.display()),
            })
        })?;

        let file_name = entry.file_name();
        let file_name_str = file_name.to_string_lossy();

        // Skip the advisory lock file -- it is process-specific.
        if file_name_str == "tidaldb.lock" {
            continue;
        }

        // Skip fjall temporary files (created and removed during compaction).
        // These begin with `.tmp` and are transient -- they may vanish between
        // readdir and copy, causing spurious ENOENT errors.
        if file_name_str.starts_with(".tmp") {
            continue;
        }

        let src_path = entry.path();
        let dest_path = dest.join(&file_name);

        let file_type = match entry.file_type() {
            Ok(ft) => ft,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                // File vanished between readdir and file_type -- skip it.
                tracing::debug!(path = %src_path.display(), "backup: file vanished, skipping");
                continue;
            }
            Err(e) => {
                return Err(TidalError::Durability(DurabilityError {
                    message: format!("failed to read file type for {}: {e}", src_path.display()),
                }));
            }
        };

        if file_type.is_dir() {
            std::fs::create_dir_all(&dest_path).map_err(|e| {
                TidalError::Durability(DurabilityError {
                    message: format!("failed to create directory {}: {e}", dest_path.display()),
                })
            })?;
            total_bytes += copy_dir_recursive(&src_path, &dest_path)?;
        } else if file_type.is_file() {
            match std::fs::copy(&src_path, &dest_path) {
                Ok(bytes) => total_bytes += bytes,
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                    // File vanished between readdir and copy -- skip it.
                    // This is expected with fjall's compaction creating and
                    // removing temp files concurrently.
                    tracing::debug!(
                        path = %src_path.display(),
                        "backup: file vanished during copy, skipping"
                    );
                }
                Err(e) => {
                    return Err(TidalError::Durability(DurabilityError {
                        message: format!(
                            "failed to copy {} -> {}: {e}",
                            src_path.display(),
                            dest_path.display()
                        ),
                    }));
                }
            }
        }
        // Symlinks and other special file types are skipped.
    }

    Ok(total_bytes)
}

impl super::TidalDb {
    /// Create a backup of the database at the given destination path.
    ///
    /// The backup is **crash-consistent** as of the flush point: all data
    /// that was durable before the backup started is included. Writes are
    /// paused for the duration of the flush + copy; callers receive
    /// [`TidalError::Backpressure`] during this window.
    ///
    /// The destination directory is created if it does not exist. It must
    /// be on the same filesystem for predictable copy performance. The
    /// resulting backup can be opened directly with
    /// `TidalDb::builder().with_data_dir(dest).with_schema(schema).open()`.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if the database is ephemeral (no data directory).
    /// - `TidalError::Internal` if a backup is already in progress.
    /// - `TidalError::Durability` if the flush or file copy fails.
    pub fn create_backup(&self, dest: &Path) -> crate::Result<BackupInfo> {
        // 1. Guard: only works for persistent mode.
        let data_dir = self.config.data_dir.as_ref().ok_or_else(|| {
            TidalError::internal(
                "create_backup",
                "backup requires a persistent (non-ephemeral) database",
            )
        })?;

        // 2. Prevent concurrent backups via CAS.
        if self
            .backup_in_progress
            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
            .is_err()
        {
            return Err(TidalError::internal(
                "create_backup",
                "a backup is already in progress",
            ));
        }

        // RAII guard ensures the flag is cleared even on early return.
        let _guard = BackupGuard(&self.backup_in_progress);

        // 3. Create destination directory.
        std::fs::create_dir_all(dest).map_err(|e| {
            TidalError::Durability(DurabilityError {
                message: format!(
                    "failed to create backup destination {}: {e}",
                    dest.display()
                ),
            })
        })?;

        // 4. Checkpoint signal ledger synchronously.
        //
        // This is fatal: if the checkpoint fails, WAL replay on restore would
        // double-count signals (replay re-applies events that were never
        // persisted to the checkpoint keyspace).
        let seq = self.last_wal_seq.load(Ordering::Acquire);
        if let (Some(ledger), Some(storage)) = (&self.ledger, &self.storage) {
            let meta = crate::signals::checkpoint::CheckpointMeta {
                checkpoint_time_ns: Timestamp::now().as_nanos(),
                wal_sequence: seq,
                payload_hash: [0u8; 32],
            };
            ledger
                .checkpoint(storage.items_engine(), meta)
                .map_err(|e| {
                    TidalError::Durability(DurabilityError {
                        message: format!("backup: signal checkpoint failed: {e}"),
                    })
                })?;

            // Cohort and co-engagement checkpoints are secondary state; log
            // and continue rather than aborting the backup if they fail.
            if self.cohort_ledger.entry_count() > 0
                && let Err(e) = self.cohort_ledger.checkpoint(storage.items_engine(), meta)
            {
                tracing::warn!(error = %e, "backup: cohort checkpoint failed (non-fatal)");
            }

            if self.co_engagement.edge_count() > 0
                && let Err(e) = self.co_engagement.checkpoint(storage.items_engine())
            {
                tracing::warn!(error = %e, "backup: co-engagement checkpoint failed (non-fatal)");
            }
        }

        // 5. Flush text indexes (non-fatal: search indexes are rebuilt on
        //    demand; their absence does not affect data integrity).
        if let Err(e) = self.flush_text_index() {
            tracing::warn!(error = %e, "backup: text index flush failed (non-fatal)");
        }
        if let Err(e) = self.flush_creator_text_index() {
            tracing::warn!(error = %e, "backup: creator text index flush failed (non-fatal)");
        }

        // 6. Flush fjall storage (all keyspaces).
        //
        // This is fatal: if the memtable cannot be flushed, the backup
        // directory will be missing recently written data. Callers can
        // retry once disk pressure subsides.
        if let Some(storage) = &self.storage {
            storage.flush().map_err(|e| {
                TidalError::Durability(DurabilityError {
                    message: format!("backup: storage flush failed: {e}"),
                })
            })?;
        }

        // 7. Copy data directory to destination.
        let size_bytes = copy_dir_recursive(data_dir, dest)?;

        // 8. Write a WAL checkpoint marker into the backup's WAL directory
        //    so that WAL replay on the next open of the backup skips events
        //    that were already materialized by the signal checkpoint above.
        //    Without this, signals would be double-counted (once from the
        //    checkpoint, once from WAL replay).
        if seq > 0 {
            let backup_wal_dir = dest.join("wal");
            if backup_wal_dir.exists() {
                let ts_nanos = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_nanos();
                #[allow(clippy::cast_possible_truncation)]
                let ts_u64 = ts_nanos as u64;
                if let Err(e) =
                    crate::wal::checkpoint::CheckpointManager::write(&backup_wal_dir, seq, ts_u64)
                {
                    tracing::warn!(
                        error = %e,
                        "backup: failed to write WAL checkpoint marker (non-fatal)"
                    );
                }
            }
        }

        let item_count = self.item_count();
        let timestamp_ns = Timestamp::now().as_nanos();

        tracing::info!(
            dest = %dest.display(),
            item_count,
            size_bytes,
            "backup completed"
        );

        Ok(BackupInfo {
            path: dest.to_path_buf(),
            item_count,
            timestamp_ns,
            size_bytes,
        })
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn copy_dir_recursive_creates_files() {
        let src = tempfile::tempdir().unwrap();
        let dest = tempfile::tempdir().unwrap();

        // Create a file in src.
        std::fs::write(src.path().join("test.txt"), "hello").unwrap();
        std::fs::create_dir(src.path().join("subdir")).unwrap();
        std::fs::write(src.path().join("subdir").join("nested.txt"), "world").unwrap();

        let bytes = copy_dir_recursive(src.path(), dest.path()).unwrap();
        assert!(bytes > 0);
        assert!(dest.path().join("test.txt").exists());
        assert!(dest.path().join("subdir").join("nested.txt").exists());

        let content = std::fs::read_to_string(dest.path().join("test.txt")).unwrap();
        assert_eq!(content, "hello");
    }

    #[test]
    fn copy_dir_recursive_skips_lock_file() {
        let src = tempfile::tempdir().unwrap();
        let dest = tempfile::tempdir().unwrap();

        std::fs::write(src.path().join("tidaldb.lock"), "").unwrap();
        std::fs::write(src.path().join("data.db"), "data").unwrap();

        copy_dir_recursive(src.path(), dest.path()).unwrap();
        assert!(!dest.path().join("tidaldb.lock").exists());
        assert!(dest.path().join("data.db").exists());
    }

    #[test]
    fn backup_fails_for_ephemeral() {
        let db = crate::TidalDb::builder().ephemeral().open().unwrap();
        let bak_dir = tempfile::tempdir().unwrap();
        let result = db.create_backup(bak_dir.path());
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("persistent"),
            "expected persistent error, got: {err}"
        );
    }
}
