//! 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** against a host/power crash: it captures
//! a point-in-time snapshot as of the flush, and every copied file plus each
//! destination directory is `fsync`ed before `create_backup` returns, so the
//! artifact is durable (not merely sitting in the page cache) the moment the
//! call completes. It is not taken under a hot write path; any signals written
//! after the backup resumes are not included.

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

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

/// `fsync` a single path (file or directory) so its bytes / directory entries
/// are durable, mirroring the WAL layer's barrier (`wal/segment.rs` `open`/
/// `rotate` both `File::open(dir)` + `sync_all()`). A backup that returns before
/// its copied files and the new dirents are `fsync`ed is only crash-consistent
/// against a *process* crash, not a host/power crash — the exact crash backups
/// exist to survive.
///
/// # Errors
///
/// Returns `TidalError::Durability` if the open or `sync_all` fails.
fn fsync_path(path: &Path) -> crate::Result<()> {
    let f = File::open(path).map_err(|e| {
        TidalError::Durability(DurabilityError {
            message: format!("backup: failed to open {} for fsync: {e}", path.display()),
        })
    })?;
    f.sync_all().map_err(|e| {
        TidalError::Durability(DurabilityError {
            message: format!("backup: fsync of {} failed: {e}", path.display()),
        })
    })
}

/// 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).
///
/// Every copied file is `fsync`ed after its bytes are written, and `dest`
/// itself is `fsync`ed once the level is fully populated so the new directory
/// entries are durable. Without this, a host/power crash in the window after
/// `create_backup` returns could leave copied files zero-length or their
/// dirents unlinked — a corrupt backup discovered only at restore. This
/// matches the WAL layer, which already `fsync`s files and parent dirs.
///
/// # Errors
///
/// Returns `TidalError::Durability` if any filesystem operation (copy, create,
/// or `fsync`) fails.
fn copy_dir_recursive(src: &Path, dest: &Path) -> crate::Result<u64> {
    let mut total_bytes: u64 = 0;
    // Whether this level received at least one entry (file copied or subdir
    // created). If so, we must fsync `dest` to make the new dirents durable.
    let mut dest_dirty = false;

    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)?;
            dest_dirty = true;
        } else if file_type.is_file() {
            match std::fs::copy(&src_path, &dest_path) {
                Ok(bytes) => {
                    total_bytes += bytes;
                    // Make the copied bytes durable before we move on. Without
                    // this, a power crash can leave a zero-length or torn file.
                    fsync_path(&dest_path)?;
                    dest_dirty = true;
                }
                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.
    }

    // Fsync this directory so its new entries (the files we just copied and the
    // subdirectories we created) survive a host crash. A child directory's own
    // entries were already fsync'd by the recursive call before it returned.
    if dest_dirty {
        fsync_path(dest)?;
    }

    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 / co-engagement / community / preference checkpoints are
            // secondary derived state with no WAL backstop; log and continue
            // rather than aborting the backup if any fails.
            self.checkpoint_secondary_state(storage, meta);
        }

        // 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)");
        }

        // 5b. WAL writer in-flight events: why the copy below is still consistent.
        //
        // `backup_in_progress` (set by the CAS above) makes every NEW `signal()`
        // return Backpressure before it enqueues. The only writes it does NOT
        // cover are events a concurrent `signal()` already enqueued before it
        // observed the flag — those sit in the writer's crossbeam channel,
        // possibly mid-fsync, while we copy. We deliberately do NOT block on them,
        // and the resulting backup is still crash-consistent, for two reasons:
        //
        //  1. Torn tail batch in a copied segment — `WalHandle::open` runs
        //     `reader::recover`, which two-phase-validates every batch (magic +
        //     bounds, then BLAKE3) and truncates any corrupted/half-written tail.
        //     A copy that captured a partially-fsynced tail batch is repaired on
        //     the next open of the backup, exactly as a crash mid-write would be.
        //
        //  2. An enqueued-but-unflushed event missing from the copy — that event
        //     belongs to a `signal()` call still blocked in `WalHandle::append`
        //     (which only returns AFTER its batch is fsynced). The caller has not
        //     yet been told the write succeeded, so its absence from the backup is
        //     identical to a write that lost a race with a crash — an acceptable,
        //     un-acked loss, not an inconsistency. Any in-flight event that DID
        //     reach a copied segment carries seq > the WAL checkpoint marker
        //     written in step 8, so backup replay re-applies it without
        //     double-counting against the ledger checkpoint from step 4.
        //
        // A *guaranteed* drain (capturing acked-but-just-enqueued writes too)
        // would need a reply-after-fsync barrier the WAL does not yet expose;
        // `truncate_before` cannot serve as one because its reply is sent before
        // the in-progress batch is flushed. Tracked for a future
        // `WalCommand::Flush`; the current behavior is already crash-consistent.

        // 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() {
                // Use the same clock as every other timestamp in this method
                // (`Timestamp::now()`), rather than a raw `SystemTime` read, so the
                // WAL-marker time, the ledger checkpoint time (step 4), and the
                // returned `BackupInfo.timestamp_ns` all share one clock source.
                let ts_u64 = Timestamp::now().as_nanos();
                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,
        })
    }

    /// Checkpoint the secondary derived ledgers (cohort, co-engagement,
    /// community, preference vectors) into `storage`.
    ///
    /// Each is non-fatal on its own: these are derived aggregates whose absence
    /// degrades but never corrupts ranking, so a failure is logged and the others
    /// still run. Delegates to the ONE shared `checkpoint_secondary_ledgers`
    /// helper (in `db::state_rebuild`) so the backup, shutdown, and periodic paths
    /// share one set of guards and cannot drift. The first error is discarded
    /// here: a missing secondary ledger in a backup degrades ranking but never
    /// corrupts data, and the signal-ledger checkpoint (the fatal one) ran before
    /// this.
    fn checkpoint_secondary_state(
        &self,
        storage: &super::storage_box::StorageBox,
        meta: crate::signals::checkpoint::CheckpointMeta,
    ) {
        let _ = crate::db::state_rebuild::checkpoint_secondary_ledgers(
            storage.items_engine(),
            Some((self.cohort_ledger.as_ref(), meta)),
            self.community_ledger.as_ref(),
            self.co_engagement.as_ref(),
            self.preference_vectors.as_ref(),
        );
    }
}

#[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 fsync_path_durably_syncs_file_and_dir() {
        // The fsync helper must succeed on both a regular file and a directory —
        // the two call sites in copy_dir_recursive. A failure here means a copied
        // backup file or its dirent would never be made durable.
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("f.bin");
        std::fs::write(&file, b"durable").unwrap();
        fsync_path(&file).expect("file fsync must succeed");
        fsync_path(dir.path()).expect("directory fsync must succeed");
    }

    #[test]
    fn copy_dir_recursive_fsyncs_every_copied_file_and_dir() {
        // Durability regression: copy_dir_recursive must fsync each copied file
        // and each destination directory (nested too). We cannot observe the
        // fsync syscall directly without fault injection, but we can prove the
        // path that fsyncs every file/dir runs to completion over a tree with a
        // nested directory, and that the bytes landed correctly. The fsync calls
        // are unconditional on the copy/create branches, so a regression that
        // dropped them would either fail to compile (helper removed) or be caught
        // by the helper's own error path surfacing here.
        let src = tempfile::tempdir().unwrap();
        let dest = tempfile::tempdir().unwrap();

        std::fs::write(src.path().join("top.txt"), "top").unwrap();
        std::fs::create_dir(src.path().join("a")).unwrap();
        std::fs::write(src.path().join("a").join("inner.txt"), "inner").unwrap();
        std::fs::create_dir(src.path().join("a").join("b")).unwrap();
        std::fs::write(src.path().join("a").join("b").join("deep.txt"), "deep").unwrap();

        let bytes = copy_dir_recursive(src.path(), dest.path()).unwrap();
        assert!(bytes > 0);
        assert_eq!(
            std::fs::read_to_string(dest.path().join("top.txt")).unwrap(),
            "top"
        );
        assert_eq!(
            std::fs::read_to_string(dest.path().join("a").join("inner.txt")).unwrap(),
            "inner"
        );
        assert_eq!(
            std::fs::read_to_string(dest.path().join("a").join("b").join("deep.txt")).unwrap(),
            "deep"
        );
    }

    #[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}"
        );
    }

    /// Minimal schema: one Item "view" signal over the `AllTime` window
    /// (ephemeral WITHOUT a schema has no storage/ledger).
    fn minimal_schema() -> crate::schema::Schema {
        use std::time::Duration;

        use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Window};

        let mut builder = SchemaBuilder::new();
        let _ = builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        builder.build().expect("schema must be valid")
    }

    /// Every signal whose `signal()` call has RETURNED before the backup must be
    /// present in the restored backup — this is the consistency guarantee that
    /// makes the no-explicit-WAL-flush copy safe (`WalHandle::append` only returns
    /// after its batch is fsynced, so an acked write is already on disk when the
    /// copy runs). The restored DB is opened fresh, exercising the full
    /// WAL-replay + torn-tail-recovery path on the copied data dir.
    #[test]
    fn backup_captures_all_acked_signals() {
        use crate::schema::{EntityId, Timestamp};

        let src = tempfile::tempdir().unwrap();
        let bak = tempfile::tempdir().unwrap();
        let backup_path = bak.path().join("backup");

        let db = crate::TidalDb::builder()
            .with_data_dir(src.path())
            .with_schema(minimal_schema())
            .open()
            .unwrap();

        // Each of these returns only after its WAL batch is durably fsynced, so by
        // the time create_backup runs they are all on disk.
        for i in 1u64..=5 {
            db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
                .unwrap();
        }

        let info = db.create_backup(&backup_path).unwrap();
        assert!(info.size_bytes > 0, "backup must copy non-empty data dir");
        db.close().unwrap();

        // Reopen the backup from scratch: WAL replay + torn-tail recovery rebuild
        // the ledger. Every acked signal's decay score must be present.
        let restored = crate::TidalDb::builder()
            .with_data_dir(&backup_path)
            .with_schema(minimal_schema())
            .open()
            .unwrap();
        for i in 1u64..=5 {
            let score = restored
                .read_decay_score(EntityId::new(i), "view", 0)
                .unwrap();
            assert!(
                score.is_some_and(|s| s > 0.0),
                "acked signal for entity {i} must survive the backup/restore round-trip"
            );
        }
        restored.close().unwrap();
    }
}
