use crate::deck_data::review_db_path_for_deck;
use crate::file_ops::{
    create_unique_temp_path, path_io_error, replace_file_durably, two_path_io_error,
    write_text_file_durably,
};
use crate::time_format::format_unix_timestamp_file_utc;
use cosimo::{SqliteReviewStore, parse_deck_file};
use std::collections::{BTreeMap, HashSet, hash_map::DefaultHasher};
use std::fs::{self, File};
use std::hash::{Hash, Hasher};
use std::io::{self, Read, Write};
use std::path::{Component, Path, PathBuf};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};

const DECK_BUNDLE_BACKUP_INDEX_FILE: &str = "deck-bundle-index.txt";
const AUTOMATIC_FULL_DECK_BACKUP_INDEX_FILE: &str = "automatic-deck-bundle-index.txt";
const AUTOMATIC_FULL_DECK_BACKUP_INTERVAL_SECONDS: i64 = 24 * 60 * 60;
const DECK_BUNDLE_DEFLATE_LEVEL: i64 = 1;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DeckBundleSummary {
    pub(crate) path: PathBuf,
    pub(crate) file_count: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DeckBundleProgress {
    pub(crate) completed: usize,
    pub(crate) total: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeckBundleRestoreProgressPhase {
    Extracting,
    BackingUpExistingTarget,
    Installing,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DeckBundleRestoreProgress {
    pub(crate) phase: DeckBundleRestoreProgressPhase,
    pub(crate) completed: usize,
    pub(crate) total: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DeckBundleRestorePreview {
    pub(crate) deck_file_name: String,
    pub(crate) target_deck_path: PathBuf,
    pub(crate) card_count: usize,
    pub(crate) audio_file_count: usize,
    pub(crate) file_count: usize,
    pub(crate) has_review_database: bool,
    pub(crate) overwritten_paths: Vec<PathBuf>,
    entries: Vec<DeckBundleEntry>,
}

impl DeckBundleRestorePreview {
    pub(crate) fn overwrites_existing_files(&self) -> bool {
        !self.overwritten_paths.is_empty()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DeckBundleRestoreSummary {
    pub(crate) preview: DeckBundleRestorePreview,
    pub(crate) backup_path: Option<PathBuf>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AutomaticDeckBundleBackupOutcome {
    Current { path: PathBuf },
    Written(DeckBundleSummary),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PruneAutomaticDeckBundleBackupsSummary {
    pub(crate) removed_count: usize,
    pub(crate) kept_count: usize,
}

struct PruneAutomaticDeckBundleBackupsPlan {
    index_path: PathBuf,
    index_exists: bool,
    files_to_remove: HashSet<String>,
    kept_entries: Vec<AutomaticDeckBundleBackupIndexEntry>,
}

impl PruneAutomaticDeckBundleBackupsPlan {
    fn summary(&self) -> PruneAutomaticDeckBundleBackupsSummary {
        PruneAutomaticDeckBundleBackupsSummary {
            removed_count: self.files_to_remove.len(),
            kept_count: self.kept_entries.len(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct DeckBundleEntry {
    archive_path: PathBuf,
    relative_path: PathBuf,
    is_dir: bool,
}

pub(crate) fn deck_bundle_backup_index_path(backup_dir: &Path) -> PathBuf {
    backup_dir.join(DECK_BUNDLE_BACKUP_INDEX_FILE)
}

pub(crate) fn automatic_deck_bundle_backup_index_path(backup_dir: &Path) -> PathBuf {
    backup_dir.join(AUTOMATIC_FULL_DECK_BACKUP_INDEX_FILE)
}

pub(crate) fn deck_bundle_default_file_name(
    deck_path: &Path,
    timestamp_unix_seconds: i64,
) -> String {
    format!(
        "{}-{}.zip",
        sanitized_deck_stem(deck_path),
        format_unix_timestamp_file_utc(timestamp_unix_seconds)
    )
}

pub(crate) fn deck_bundle_with_study_history_default_file_name(
    deck_path: &Path,
    timestamp_unix_seconds: i64,
) -> String {
    format!(
        "{}-with-study-history-{}.zip",
        sanitized_deck_stem(deck_path),
        format_unix_timestamp_file_utc(timestamp_unix_seconds)
    )
}

pub(crate) fn deck_bundle_backup_path(
    deck_path: &Path,
    backup_dir: &Path,
    timestamp_unix_seconds: i64,
) -> PathBuf {
    unique_bundle_path(
        backup_dir,
        &sanitized_deck_stem(deck_path),
        &format_unix_timestamp_file_utc(timestamp_unix_seconds),
    )
}

pub(crate) fn automatic_deck_bundle_backup_path(deck_path: &Path, backup_dir: &Path) -> PathBuf {
    let original_path = canonical_path_text(deck_path);
    let mut hasher = DefaultHasher::new();
    original_path.hash(&mut hasher);
    let path_hash = hasher.finish();
    backup_dir.join(format!(
        "automatic-full-deck-{path_hash:016x}-{}.zip",
        sanitized_deck_stem(deck_path)
    ))
}

pub(crate) fn write_deck_bundle(
    deck_path: &Path,
    destination: &Path,
) -> io::Result<DeckBundleSummary> {
    write_deck_bundle_with_progress(deck_path, destination, |_| {})
}

pub(crate) fn write_deck_bundle_with_progress(
    deck_path: &Path,
    destination: &Path,
    mut progress: impl FnMut(DeckBundleProgress),
) -> io::Result<DeckBundleSummary> {
    write_deck_bundle_with_optional_review_database(deck_path, destination, None, &mut progress)
}

pub(crate) fn write_deck_bundle_with_review_database(
    deck_path: &Path,
    review_db_path: &Path,
    store: &SqliteReviewStore,
    destination: &Path,
) -> io::Result<DeckBundleSummary> {
    write_deck_bundle_with_review_database_with_progress(
        deck_path,
        review_db_path,
        store,
        destination,
        |_| {},
    )
}

pub(crate) fn write_deck_bundle_with_review_database_with_progress(
    deck_path: &Path,
    review_db_path: &Path,
    store: &SqliteReviewStore,
    destination: &Path,
    mut progress: impl FnMut(DeckBundleProgress),
) -> io::Result<DeckBundleSummary> {
    ensure_destination_parent(destination)?;
    let snapshot_path = create_unique_temp_path(destination)?;
    let snapshot_result = store.backup_to_path(&snapshot_path).map_err(|error| {
        two_path_io_error(
            "could not snapshot review database",
            review_db_path,
            &snapshot_path,
            io::Error::other(error.to_string()),
        )
    });
    if let Err(error) = snapshot_result {
        let _ = fs::remove_file(&snapshot_path);
        return Err(error);
    }
    let result = write_deck_bundle_with_optional_review_database(
        deck_path,
        destination,
        Some(ReviewDatabaseSnapshot {
            snapshot_path: &snapshot_path,
            original_path: review_db_path,
        }),
        &mut progress,
    );
    let _ = fs::remove_file(snapshot_path);
    result
}

#[allow(dead_code)]
pub(crate) fn write_content_only_deck_bundle_from_bundle(
    source_bundle_path: &Path,
    destination: &Path,
) -> io::Result<DeckBundleSummary> {
    if same_path(source_bundle_path, destination) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "content-only bundle derivation requires separate source and destination paths",
        ));
    }
    ensure_destination_parent(destination)?;
    let preview = inspect_deck_bundle_restore(source_bundle_path, Path::new(""))?;
    let expected_review_db_name = review_db_path_for_deck(Path::new(&preview.deck_file_name))
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .ok_or_else(|| invalid_bundle("could not derive the review database file name"))?
        .to_string();

    let temp_path = create_unique_temp_path(destination)?;
    let write_result = write_content_only_bundle_to_temp(
        source_bundle_path,
        &temp_path,
        &preview,
        &expected_review_db_name,
    );
    if write_result.is_err() {
        let _ = fs::remove_file(&temp_path);
    }
    let file_count = write_result?;
    replace_file_durably(&temp_path, destination)?;
    Ok(DeckBundleSummary {
        path: destination.to_path_buf(),
        file_count,
    })
}

fn write_deck_bundle_with_optional_review_database(
    deck_path: &Path,
    destination: &Path,
    review_database: Option<ReviewDatabaseSnapshot<'_>>,
    progress: &mut dyn FnMut(DeckBundleProgress),
) -> io::Result<DeckBundleSummary> {
    if !deck_path.is_file() {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!("deck file not found: {}", deck_path.display()),
        ));
    }
    ensure_destination_parent(destination)?;

    let temp_path = create_unique_temp_path(destination)?;
    let total_files =
        planned_deck_bundle_file_count(deck_path, destination, &temp_path, review_database)?;
    progress(DeckBundleProgress {
        completed: 0,
        total: total_files,
    });
    let write_result = write_deck_bundle_to_temp(
        deck_path,
        destination,
        &temp_path,
        review_database,
        progress,
    );
    if write_result.is_err() {
        let _ = fs::remove_file(&temp_path);
    }
    let file_count = write_result?;
    replace_file_durably(&temp_path, destination)?;
    Ok(DeckBundleSummary {
        path: destination.to_path_buf(),
        file_count,
    })
}

fn ensure_destination_parent(destination: &Path) -> io::Result<()> {
    if let Some(parent) = destination.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent).map_err(|error| {
            path_io_error(
                "could not create bundle destination directory",
                parent,
                error,
            )
        })?;
    }
    Ok(())
}

#[derive(Clone, Copy)]
struct ReviewDatabaseSnapshot<'a> {
    snapshot_path: &'a Path,
    original_path: &'a Path,
}

pub(crate) fn write_deck_bundle_backup_with_progress(
    deck_path: &Path,
    backup_dir: &Path,
    timestamp_unix_seconds: i64,
    mut progress: impl FnMut(DeckBundleProgress),
) -> io::Result<DeckBundleSummary> {
    fs::create_dir_all(backup_dir)
        .map_err(|error| path_io_error("could not create backup directory", backup_dir, error))?;
    let backup_path = deck_bundle_backup_path(deck_path, backup_dir, timestamp_unix_seconds);
    let summary = write_deck_bundle_with_progress(deck_path, &backup_path, &mut progress)?;
    record_deck_bundle_backup_index(deck_path, &summary.path)?;
    Ok(summary)
}

pub(crate) fn write_deck_bundle_backup_with_review_database_with_progress(
    deck_path: &Path,
    review_db_path: &Path,
    backup_dir: &Path,
    timestamp_unix_seconds: i64,
    mut progress: impl FnMut(DeckBundleProgress),
) -> io::Result<DeckBundleSummary> {
    if !review_db_path.is_file() {
        return write_deck_bundle_backup_with_progress(
            deck_path,
            backup_dir,
            timestamp_unix_seconds,
            progress,
        );
    }

    fs::create_dir_all(backup_dir)
        .map_err(|error| path_io_error("could not create backup directory", backup_dir, error))?;
    let backup_path = deck_bundle_backup_path(deck_path, backup_dir, timestamp_unix_seconds);
    let store = SqliteReviewStore::open(review_db_path).map_err(|error| {
        path_io_error(
            "could not open review database for bundle backup",
            review_db_path,
            io::Error::other(error.to_string()),
        )
    })?;
    let summary = write_deck_bundle_with_review_database_with_progress(
        deck_path,
        review_db_path,
        &store,
        &backup_path,
        &mut progress,
    )?;
    record_deck_bundle_backup_index(deck_path, &summary.path)?;
    Ok(summary)
}

pub(crate) fn write_automatic_deck_bundle_backup_with_review_database_if_due(
    deck_path: &Path,
    review_db_path: &Path,
    store: &SqliteReviewStore,
    backup_dir: &Path,
    now_unix_seconds: i64,
) -> io::Result<AutomaticDeckBundleBackupOutcome> {
    fs::create_dir_all(backup_dir)
        .map_err(|error| path_io_error("could not create backup directory", backup_dir, error))?;
    if let Some(current_path) =
        current_automatic_deck_bundle_backup_path(deck_path, backup_dir, now_unix_seconds)?
    {
        return Ok(AutomaticDeckBundleBackupOutcome::Current { path: current_path });
    }

    let backup_path = automatic_deck_bundle_backup_path(deck_path, backup_dir);
    let summary = if review_db_path.is_file() {
        write_deck_bundle_with_review_database(deck_path, review_db_path, store, &backup_path)?
    } else {
        write_deck_bundle(deck_path, &backup_path)?
    };
    record_automatic_deck_bundle_backup_index(deck_path, &summary.path, now_unix_seconds)?;
    Ok(AutomaticDeckBundleBackupOutcome::Written(summary))
}

pub(crate) fn prune_obsolete_automatic_deck_bundle_backups(
    backup_dir: &Path,
) -> io::Result<PruneAutomaticDeckBundleBackupsSummary> {
    let plan = plan_obsolete_automatic_deck_bundle_backups(backup_dir)?;
    if !plan.index_exists {
        return Ok(plan.summary());
    }
    let PruneAutomaticDeckBundleBackupsPlan {
        index_path,
        files_to_remove,
        kept_entries,
        ..
    } = plan;

    let mut removed_count = 0;
    for file_name in files_to_remove {
        let Some(backup_path) = automatic_deck_bundle_indexed_backup_path(backup_dir, &file_name)
        else {
            continue;
        };
        if backup_path.try_exists().map_err(|error| {
            path_io_error(
                "could not check automatic full-deck backup file",
                &backup_path,
                error,
            )
        })? {
            fs::remove_file(&backup_path).map_err(|error| {
                path_io_error(
                    "could not remove obsolete automatic full-deck backup file",
                    &backup_path,
                    error,
                )
            })?;
            removed_count += 1;
        }
    }

    let mut rewritten_index = String::new();
    for entry in &kept_entries {
        rewritten_index.push_str(&format!(
            "{}\t{}\t{}\n",
            entry.backup_file_name, entry.timestamp_unix_seconds, entry.original_path
        ));
    }
    write_text_file_durably(index_path, &rewritten_index)?;

    Ok(PruneAutomaticDeckBundleBackupsSummary {
        removed_count,
        kept_count: kept_entries.len(),
    })
}

pub(crate) fn preview_obsolete_automatic_deck_bundle_backups(
    backup_dir: &Path,
) -> io::Result<PruneAutomaticDeckBundleBackupsSummary> {
    plan_obsolete_automatic_deck_bundle_backups(backup_dir).map(|plan| plan.summary())
}

fn plan_obsolete_automatic_deck_bundle_backups(
    backup_dir: &Path,
) -> io::Result<PruneAutomaticDeckBundleBackupsPlan> {
    let index_path = automatic_deck_bundle_backup_index_path(backup_dir);
    if !index_path.try_exists().map_err(|error| {
        path_io_error(
            "could not check automatic full-deck backup index",
            &index_path,
            error,
        )
    })? {
        return Ok(PruneAutomaticDeckBundleBackupsPlan {
            index_path,
            index_exists: false,
            files_to_remove: HashSet::new(),
            kept_entries: Vec::new(),
        });
    }

    let index_text = fs::read_to_string(&index_path).map_err(|error| {
        path_io_error(
            "could not read automatic full-deck backup index",
            &index_path,
            error,
        )
    })?;
    let mut newest_by_original_path: BTreeMap<String, AutomaticDeckBundleBackupIndexEntry> =
        BTreeMap::new();
    let mut obsolete_entries = Vec::new();

    for entry in parse_automatic_deck_bundle_backup_index(&index_text) {
        let Some(backup_path) =
            automatic_deck_bundle_indexed_backup_path(backup_dir, &entry.backup_file_name)
        else {
            continue;
        };
        if !backup_path.try_exists().map_err(|error| {
            path_io_error(
                "could not check automatic full-deck backup file",
                &backup_path,
                error,
            )
        })? {
            continue;
        }

        match newest_by_original_path.get(&entry.original_path) {
            Some(existing) if existing.timestamp_unix_seconds > entry.timestamp_unix_seconds => {
                obsolete_entries.push(entry);
            }
            Some(_) => {
                let previous = newest_by_original_path
                    .insert(entry.original_path.clone(), entry)
                    .expect("entry exists");
                obsolete_entries.push(previous);
            }
            None => {
                newest_by_original_path.insert(entry.original_path.clone(), entry);
            }
        }
    }

    let kept_entries: Vec<_> = newest_by_original_path.into_values().collect();
    let kept_file_names: HashSet<_> = kept_entries
        .iter()
        .map(|entry| entry.backup_file_name.as_str())
        .collect();
    let mut files_to_remove = HashSet::new();
    for entry in obsolete_entries {
        files_to_remove.insert(entry.backup_file_name);
    }

    if backup_dir
        .try_exists()
        .map_err(|error| path_io_error("could not check backup directory", backup_dir, error))?
    {
        for entry in fs::read_dir(backup_dir)
            .map_err(|error| path_io_error("could not read backup directory", backup_dir, error))?
        {
            let entry = entry.map_err(|error| {
                path_io_error("could not read backup directory entry", backup_dir, error)
            })?;
            let file_name = entry.file_name();
            let Some(file_name) = file_name.to_str() else {
                continue;
            };
            if is_fixed_automatic_deck_bundle_backup_file_name(file_name)
                && !kept_file_names.contains(file_name)
            {
                files_to_remove.insert(file_name.to_string());
            }
        }
    }

    Ok(PruneAutomaticDeckBundleBackupsPlan {
        index_path,
        index_exists: true,
        files_to_remove,
        kept_entries,
    })
}

fn current_automatic_deck_bundle_backup_path(
    deck_path: &Path,
    backup_dir: &Path,
    now_unix_seconds: i64,
) -> io::Result<Option<PathBuf>> {
    let original_path = canonical_path_text(deck_path);
    let index_path = automatic_deck_bundle_backup_index_path(backup_dir);
    let index_text = match fs::read_to_string(&index_path) {
        Ok(index_text) => index_text,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(path_io_error(
                "could not read automatic full-deck backup index",
                &index_path,
                error,
            ));
        }
    };
    let Some(entry) = parse_automatic_deck_bundle_backup_index(&index_text)
        .into_iter()
        .find(|entry| entry.original_path == original_path)
    else {
        return Ok(None);
    };
    let Some(indexed_backup_path) =
        automatic_deck_bundle_indexed_backup_path(backup_dir, &entry.backup_file_name)
    else {
        return Ok(None);
    };
    if !indexed_backup_path.try_exists().map_err(|error| {
        path_io_error(
            "could not check automatic full-deck backup file",
            &indexed_backup_path,
            error,
        )
    })? {
        return Ok(None);
    }
    Ok((entry
        .timestamp_unix_seconds
        .saturating_add(AUTOMATIC_FULL_DECK_BACKUP_INTERVAL_SECONDS)
        > now_unix_seconds)
        .then_some(indexed_backup_path))
}

pub(crate) fn inspect_deck_bundle_restore(
    bundle_path: &Path,
    decks_dir: &Path,
) -> io::Result<DeckBundleRestorePreview> {
    let file = File::open(bundle_path)
        .map_err(|error| path_io_error("could not open deck bundle", bundle_path, error))?;
    let mut archive = ZipArchive::new(file).map_err(|error| {
        path_io_error("could not read deck bundle", bundle_path, zip_error(error))
    })?;
    let mut entries = Vec::new();
    let mut seen_paths = HashSet::new();
    let mut deck_entry: Option<(usize, PathBuf)> = None;

    for index in 0..archive.len() {
        let entry = archive.by_index(index).map_err(zip_error)?;
        let relative_path = safe_archive_path(entry.name(), entry.enclosed_name())?;
        if !seen_paths.insert(relative_path.clone()) {
            return Err(invalid_bundle(format!(
                "duplicate archive entry: {}",
                relative_path.display()
            )));
        }

        if is_root_deck_path(&relative_path) {
            if entry.is_dir() {
                return Err(invalid_bundle("the deck entry is a directory"));
            }
            if deck_entry.is_some() {
                return Err(invalid_bundle(
                    "the bundle contains more than one root deck file",
                ));
            }
            deck_entry = Some((index, relative_path.clone()));
        }

        entries.push(DeckBundleEntry {
            archive_path: relative_path.clone(),
            relative_path,
            is_dir: entry.is_dir(),
        });
    }

    let Some((deck_index, deck_relative_path)) = deck_entry else {
        return Err(invalid_bundle(
            "the bundle does not contain a root .cosimo-deck file",
        ));
    };
    let deck_file_name = single_component_string(&deck_relative_path)?;
    let deck_stem = Path::new(&deck_file_name)
        .file_stem()
        .and_then(|stem| stem.to_str())
        .filter(|stem| !stem.is_empty())
        .ok_or_else(|| invalid_bundle("the deck file has no usable stem"))?
        .to_string();
    let expected_review_db_name = review_db_path_for_deck(Path::new(&deck_file_name))
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .ok_or_else(|| invalid_bundle("could not derive the review database file name"))?
        .to_string();

    let deck_text = read_zip_entry_to_string(&mut archive, deck_index)?;
    let deck_file = parse_deck_file(&deck_text)
        .map_err(|error| invalid_bundle(format!("could not read the deck file: {error}")))?;

    let mut file_count = 0;
    let mut audio_file_count = 0;
    let mut has_review_database = false;
    let mut overwritten_paths = Vec::new();

    for entry in &entries {
        validate_restore_entry(
            entry,
            &deck_relative_path,
            &deck_stem,
            &expected_review_db_name,
        )?;
        if !entry.is_dir {
            file_count += 1;
            if entry.relative_path == Path::new(&expected_review_db_name) {
                has_review_database = true;
            }
            if is_audio_entry(&entry.relative_path, &deck_stem) {
                audio_file_count += 1;
            }
            let target_path = decks_dir.join(&entry.relative_path);
            if target_path.exists() {
                overwritten_paths.push(target_path);
            }
        }
    }
    overwritten_paths.sort();
    overwritten_paths.dedup();

    Ok(DeckBundleRestorePreview {
        deck_file_name,
        target_deck_path: decks_dir.join(&deck_relative_path),
        card_count: deck_file.data().len(),
        audio_file_count,
        file_count,
        has_review_database,
        overwritten_paths,
        entries,
    })
}

pub(crate) fn retarget_deck_bundle_restore_preview(
    preview: &DeckBundleRestorePreview,
    target_deck_path: &Path,
) -> io::Result<DeckBundleRestorePreview> {
    let target_dir = target_deck_path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let target_deck_file_name = target_deck_path
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .ok_or_else(|| invalid_bundle("restore target deck path has no usable file name"))?;
    if !is_root_deck_path(Path::new(target_deck_file_name)) {
        return Err(invalid_bundle(format!(
            "restore target must be a root .cosimo-deck file: {target_deck_file_name}"
        )));
    }

    let source_deck_file_name = preview.deck_file_name.as_str();
    if source_deck_file_name == target_deck_file_name {
        let mut retargeted = preview.clone();
        retargeted.target_deck_path = target_dir.join(target_deck_file_name);
        retargeted.overwritten_paths = restore_overwritten_paths(&retargeted.entries, target_dir);
        return Ok(retargeted);
    }

    let source_deck_stem = Path::new(source_deck_file_name)
        .file_stem()
        .and_then(|stem| stem.to_str())
        .filter(|stem| !stem.is_empty())
        .ok_or_else(|| invalid_bundle("source deck file has no usable stem"))?;
    let target_deck_stem = Path::new(target_deck_file_name)
        .file_stem()
        .and_then(|stem| stem.to_str())
        .filter(|stem| !stem.is_empty())
        .ok_or_else(|| invalid_bundle("restore target deck file has no usable stem"))?;
    let source_review_db_name = review_db_path_for_deck(Path::new(source_deck_file_name))
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .ok_or_else(|| invalid_bundle("could not derive source review database file name"))?
        .to_string();
    let target_review_db_name = review_db_path_for_deck(Path::new(target_deck_file_name))
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .ok_or_else(|| invalid_bundle("could not derive target review database file name"))?
        .to_string();

    let mut entries = Vec::with_capacity(preview.entries.len());
    for entry in &preview.entries {
        entries.push(DeckBundleEntry {
            archive_path: entry.archive_path.clone(),
            relative_path: retarget_restore_relative_path(
                &entry.relative_path,
                source_deck_file_name,
                source_deck_stem,
                &source_review_db_name,
                target_deck_file_name,
                target_deck_stem,
                &target_review_db_name,
            )?,
            is_dir: entry.is_dir,
        });
    }
    let overwritten_paths = restore_overwritten_paths(&entries, target_dir);

    Ok(DeckBundleRestorePreview {
        deck_file_name: target_deck_file_name.to_string(),
        target_deck_path: target_dir.join(target_deck_file_name),
        card_count: preview.card_count,
        audio_file_count: preview.audio_file_count,
        file_count: preview.file_count,
        has_review_database: preview.has_review_database,
        overwritten_paths,
        entries,
    })
}

pub(crate) fn restore_deck_bundle_from_preview(
    bundle_path: &Path,
    decks_dir: &Path,
    backup_dir: &Path,
    timestamp_unix_seconds: i64,
    preview: &DeckBundleRestorePreview,
) -> io::Result<DeckBundleRestoreSummary> {
    restore_deck_bundle_from_preview_with_progress(
        bundle_path,
        decks_dir,
        backup_dir,
        timestamp_unix_seconds,
        preview,
        |_| {},
    )
}

pub(crate) fn restore_deck_bundle_from_preview_with_progress(
    bundle_path: &Path,
    decks_dir: &Path,
    backup_dir: &Path,
    timestamp_unix_seconds: i64,
    preview: &DeckBundleRestorePreview,
    mut progress: impl FnMut(DeckBundleRestoreProgress),
) -> io::Result<DeckBundleRestoreSummary> {
    fs::create_dir_all(decks_dir)
        .map_err(|error| path_io_error("could not create decks directory", decks_dir, error))?;
    let staging_dir = create_restore_staging_dir(decks_dir)?;
    progress(DeckBundleRestoreProgress {
        phase: DeckBundleRestoreProgressPhase::Extracting,
        completed: 0,
        total: preview.file_count,
    });
    let restore_result = restore_deck_bundle_from_staging(
        bundle_path,
        decks_dir,
        backup_dir,
        timestamp_unix_seconds,
        preview,
        &staging_dir,
        &mut progress,
    );
    let cleanup_result = fs::remove_dir_all(&staging_dir);
    match (restore_result, cleanup_result) {
        (Ok(summary), _) => Ok(summary),
        (Err(error), Ok(())) => Err(error),
        (Err(error), Err(cleanup_error)) => Err(io::Error::new(
            error.kind(),
            format!(
                "{error}; additionally, Cosimo could not remove the temporary restore directory {}: {cleanup_error}",
                staging_dir.display()
            ),
        )),
    }
}

fn write_deck_bundle_to_temp(
    deck_path: &Path,
    destination: &Path,
    temp_path: &Path,
    review_database: Option<ReviewDatabaseSnapshot<'_>>,
    progress: &mut dyn FnMut(DeckBundleProgress),
) -> io::Result<usize> {
    let file = File::create(temp_path).map_err(|error| {
        path_io_error("could not create temporary deck bundle", temp_path, error)
    })?;
    let mut zip = ZipWriter::new(file);
    let review_db_path = review_db_path_for_deck(deck_path);
    let mut file_count = 0;
    let total_files =
        planned_deck_bundle_file_count(deck_path, destination, temp_path, review_database)?;

    let deck_file_name = deck_path
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .unwrap_or("deck.cosimo-deck");
    add_file_to_zip(deck_path, deck_file_name, &mut zip)?;
    file_count += 1;
    progress(DeckBundleProgress {
        completed: file_count,
        total: total_files,
    });

    if let Some(review_database) = review_database {
        let review_db_file_name = review_database
            .original_path
            .file_name()
            .and_then(|name| name.to_str())
            .filter(|name| !name.is_empty())
            .unwrap_or("deck.reviews.sqlite3");
        add_file_to_zip(review_database.snapshot_path, review_db_file_name, &mut zip)?;
        file_count += 1;
        progress(DeckBundleProgress {
            completed: file_count,
            total: total_files,
        });
    }

    let sidecar_dir = sidecar_dir_for_deck_path(deck_path);
    if sidecar_dir.is_dir() {
        add_directory_to_zip(
            &sidecar_dir,
            sidecar_dir.parent().unwrap_or_else(|| Path::new(".")),
            &review_db_path,
            destination,
            temp_path,
            review_database.map(|database| database.snapshot_path),
            &mut zip,
            &mut file_count,
            total_files,
            progress,
        )?;
    }

    let file = zip.finish().map_err(|error| {
        path_io_error(
            "could not finish temporary deck bundle",
            temp_path,
            zip_error(error),
        )
    })?;
    file.sync_all().map_err(|error| {
        path_io_error("could not flush temporary deck bundle", temp_path, error)
    })?;
    Ok(file_count)
}

#[allow(dead_code)]
fn write_content_only_bundle_to_temp(
    source_bundle_path: &Path,
    temp_path: &Path,
    preview: &DeckBundleRestorePreview,
    expected_review_db_name: &str,
) -> io::Result<usize> {
    let source_file = File::open(source_bundle_path).map_err(|error| {
        path_io_error(
            "could not open source deck bundle",
            source_bundle_path,
            error,
        )
    })?;
    let mut source_zip = ZipArchive::new(source_file).map_err(|error| {
        path_io_error(
            "could not read source deck bundle",
            source_bundle_path,
            zip_error(error),
        )
    })?;
    let target_file = File::create(temp_path).map_err(|error| {
        path_io_error("could not create temporary deck bundle", temp_path, error)
    })?;
    let mut target_zip = ZipWriter::new(target_file);
    let expected_review_db_path = Path::new(expected_review_db_name);
    let mut file_count = 0;

    for entry in &preview.entries {
        if entry.relative_path == expected_review_db_path {
            continue;
        }
        if entry.is_dir {
            let zip_name = zip_name_from_relative_path(&entry.relative_path)?;
            target_zip
                .add_directory(format!("{zip_name}/"), directory_options())
                .map_err(zip_error)?;
            continue;
        }

        copy_zip_entry_to_zip(&mut source_zip, &entry.relative_path, &mut target_zip)?;
        file_count += 1;
    }

    let file = target_zip.finish().map_err(|error| {
        path_io_error(
            "could not finish temporary deck bundle",
            temp_path,
            zip_error(error),
        )
    })?;
    file.sync_all().map_err(|error| {
        path_io_error("could not flush temporary deck bundle", temp_path, error)
    })?;
    Ok(file_count)
}

fn restore_deck_bundle_from_staging(
    bundle_path: &Path,
    decks_dir: &Path,
    backup_dir: &Path,
    timestamp_unix_seconds: i64,
    preview: &DeckBundleRestorePreview,
    staging_dir: &Path,
    progress: &mut dyn FnMut(DeckBundleRestoreProgress),
) -> io::Result<DeckBundleRestoreSummary> {
    extract_deck_bundle_to_staging(bundle_path, preview, staging_dir, progress)?;
    validate_staged_restore(preview, staging_dir)?;
    let backup_path =
        backup_existing_restore_target(preview, backup_dir, timestamp_unix_seconds, progress)?;
    install_staged_restore(preview, staging_dir, decks_dir, progress)?;
    Ok(DeckBundleRestoreSummary {
        preview: preview.clone(),
        backup_path,
    })
}

fn backup_existing_restore_target(
    preview: &DeckBundleRestorePreview,
    backup_dir: &Path,
    timestamp_unix_seconds: i64,
    progress: &mut dyn FnMut(DeckBundleRestoreProgress),
) -> io::Result<Option<PathBuf>> {
    if !preview.target_deck_path.is_file() {
        if preview.overwrites_existing_files() {
            return Err(io::Error::new(
                io::ErrorKind::AlreadyExists,
                "restore would overwrite existing files, but no target deck file exists to back up",
            ));
        }
        return Ok(None);
    }

    let review_db_path = review_db_path_for_deck(&preview.target_deck_path);
    let mut backup_progress = |bundle_progress: DeckBundleProgress| {
        progress(DeckBundleRestoreProgress {
            phase: DeckBundleRestoreProgressPhase::BackingUpExistingTarget,
            completed: bundle_progress.completed,
            total: bundle_progress.total,
        });
    };
    let summary = if review_db_path.is_file() {
        write_deck_bundle_backup_with_review_database_with_progress(
            &preview.target_deck_path,
            &review_db_path,
            backup_dir,
            timestamp_unix_seconds,
            &mut backup_progress,
        )?
    } else {
        write_deck_bundle_backup_with_progress(
            &preview.target_deck_path,
            backup_dir,
            timestamp_unix_seconds,
            &mut backup_progress,
        )?
    };
    // The restore backup is already a full pre-overwrite snapshot, so let it
    // satisfy the daily automatic full-deck backup window when indexing works.
    let _ = record_automatic_deck_bundle_backup_index(
        &preview.target_deck_path,
        &summary.path,
        timestamp_unix_seconds,
    );
    Ok(Some(summary.path))
}

fn restore_overwritten_paths(entries: &[DeckBundleEntry], target_dir: &Path) -> Vec<PathBuf> {
    let mut overwritten_paths = entries
        .iter()
        .filter(|entry| !entry.is_dir)
        .map(|entry| target_dir.join(&entry.relative_path))
        .filter(|path| path.exists())
        .collect::<Vec<_>>();
    overwritten_paths.sort();
    overwritten_paths.dedup();
    overwritten_paths
}

#[allow(clippy::too_many_arguments)]
fn retarget_restore_relative_path(
    relative_path: &Path,
    source_deck_file_name: &str,
    source_deck_stem: &str,
    source_review_db_name: &str,
    target_deck_file_name: &str,
    target_deck_stem: &str,
    target_review_db_name: &str,
) -> io::Result<PathBuf> {
    if relative_path == Path::new(source_deck_file_name) {
        return Ok(PathBuf::from(target_deck_file_name));
    }
    if relative_path == Path::new(source_review_db_name) {
        return Ok(PathBuf::from(target_review_db_name));
    }
    if is_sidecar_path(relative_path, source_deck_stem) {
        let mut retargeted = PathBuf::from(target_deck_stem);
        for component in relative_path.components().skip(1) {
            retargeted.push(component.as_os_str());
        }
        return Ok(retargeted);
    }
    Err(invalid_bundle(format!(
        "could not retarget unexpected bundle entry: {}",
        relative_path.display()
    )))
}

fn extract_deck_bundle_to_staging(
    bundle_path: &Path,
    preview: &DeckBundleRestorePreview,
    staging_dir: &Path,
    progress: &mut dyn FnMut(DeckBundleRestoreProgress),
) -> io::Result<()> {
    let file = File::open(bundle_path)
        .map_err(|error| path_io_error("could not open deck bundle", bundle_path, error))?;
    let mut archive = ZipArchive::new(file).map_err(|error| {
        path_io_error("could not read deck bundle", bundle_path, zip_error(error))
    })?;

    let mut completed = 0;
    for entry in &preview.entries {
        let target_path = staging_dir.join(&entry.relative_path);
        if entry.is_dir {
            fs::create_dir_all(&target_path).map_err(|error| {
                path_io_error(
                    "could not create restore staging directory",
                    &target_path,
                    error,
                )
            })?;
            continue;
        }
        if let Some(parent) = target_path.parent() {
            fs::create_dir_all(parent).map_err(|error| {
                path_io_error("could not create restore staging directory", parent, error)
            })?;
        }
        let zip_name = zip_name_from_relative_path(&entry.archive_path)?;
        let mut source = archive.by_name(&zip_name).map_err(zip_error)?;
        let mut target = File::create(&target_path).map_err(|error| {
            path_io_error("could not create restore staging file", &target_path, error)
        })?;
        io::copy(&mut source, &mut target).map_err(|error| {
            path_io_error("could not write restore staging file", &target_path, error)
        })?;
        completed += 1;
        progress(DeckBundleRestoreProgress {
            phase: DeckBundleRestoreProgressPhase::Extracting,
            completed,
            total: preview.file_count,
        });
    }

    Ok(())
}

fn validate_staged_restore(
    preview: &DeckBundleRestorePreview,
    staging_dir: &Path,
) -> io::Result<()> {
    let deck_path = staging_dir.join(&preview.deck_file_name);
    let deck_text = fs::read_to_string(&deck_path)
        .map_err(|error| path_io_error("could not read restored deck file", &deck_path, error))?;
    parse_deck_file(&deck_text).map_err(|error| {
        invalid_bundle(format!("could not read the restored deck file: {error}"))
    })?;

    if preview.has_review_database {
        let review_db_path = staging_dir.join(
            review_db_path_for_deck(Path::new(&preview.deck_file_name))
                .file_name()
                .ok_or_else(|| invalid_bundle("could not derive the review database file name"))?,
        );
        let _store = SqliteReviewStore::open(&review_db_path).map_err(|error| {
            path_io_error(
                "could not open restored review database",
                &review_db_path,
                io::Error::new(io::ErrorKind::InvalidData, error.to_string()),
            )
        })?;
    }

    Ok(())
}

fn install_staged_restore(
    preview: &DeckBundleRestorePreview,
    staging_dir: &Path,
    decks_dir: &Path,
    progress: &mut dyn FnMut(DeckBundleRestoreProgress),
) -> io::Result<()> {
    let mut completed = 0;
    for entry in &preview.entries {
        let target_path = decks_dir.join(&entry.relative_path);
        if entry.is_dir {
            fs::create_dir_all(&target_path).map_err(|error| {
                path_io_error(
                    "could not create restored deck directory",
                    &target_path,
                    error,
                )
            })?;
            continue;
        }
        if let Some(parent) = target_path.parent() {
            fs::create_dir_all(parent).map_err(|error| {
                path_io_error("could not create restored deck directory", parent, error)
            })?;
        }
        let source_path = staging_dir.join(&entry.relative_path);
        if restore_entry_needs_durable_replace(preview, &entry.relative_path) {
            replace_file_durably(&source_path, &target_path)?;
        } else {
            replace_restore_sidecar_file(&source_path, &target_path)?;
        }
        completed += 1;
        progress(DeckBundleRestoreProgress {
            phase: DeckBundleRestoreProgressPhase::Installing,
            completed,
            total: preview.file_count,
        });
    }
    Ok(())
}

fn restore_entry_needs_durable_replace(
    preview: &DeckBundleRestorePreview,
    relative_path: &Path,
) -> bool {
    relative_path == Path::new(&preview.deck_file_name)
        || relative_path
            == review_db_path_for_deck(Path::new(&preview.deck_file_name))
                .file_name()
                .map(Path::new)
                .unwrap_or_else(|| Path::new(""))
}

fn replace_restore_sidecar_file(source_path: &Path, target_path: &Path) -> io::Result<()> {
    match fs::remove_file(target_path) {
        Ok(()) => {}
        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
        Err(error) => {
            return Err(path_io_error(
                "could not remove existing restored sidecar file",
                target_path,
                error,
            ));
        }
    }
    fs::rename(source_path, target_path).map_err(|error| {
        two_path_io_error(
            "could not install restored sidecar file",
            source_path,
            target_path,
            error,
        )
    })
}

#[allow(dead_code)]
fn copy_zip_entry_to_zip(
    archive: &mut ZipArchive<File>,
    relative_path: &Path,
    zip: &mut ZipWriter<File>,
) -> io::Result<()> {
    let zip_name = zip_name_from_relative_path(relative_path)?;
    let mut source = archive.by_name(&zip_name).map_err(zip_error)?;
    zip.start_file(&zip_name, file_options())
        .map_err(zip_error)?;
    io::copy(&mut source, zip).map_err(|error| {
        path_io_error(
            "could not copy file into deck bundle",
            Path::new(&zip_name),
            error,
        )
    })?;
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn add_directory_to_zip(
    directory: &Path,
    base_root: &Path,
    review_db_path: &Path,
    destination: &Path,
    temp_path: &Path,
    review_db_snapshot_path: Option<&Path>,
    zip: &mut ZipWriter<File>,
    file_count: &mut usize,
    total_files: usize,
    progress: &mut dyn FnMut(DeckBundleProgress),
) -> io::Result<()> {
    let mut entries = fs::read_dir(directory)
        .map_err(|error| path_io_error("could not read sidecar directory", directory, error))?
        .collect::<Result<Vec<_>, io::Error>>()
        .map_err(|error| {
            path_io_error("could not read sidecar directory entry", directory, error)
        })?;
    entries.sort_by_key(|entry| entry.path());

    for entry in entries {
        let path = entry.path();
        if same_path(&path, destination)
            || same_path(&path, temp_path)
            || same_path(&path, review_db_path)
            || review_db_snapshot_path.is_some_and(|snapshot_path| same_path(&path, snapshot_path))
        {
            continue;
        }
        let metadata = entry
            .metadata()
            .map_err(|error| path_io_error("could not inspect sidecar entry", &path, error))?;
        let zip_name = zip_path_name(&path, base_root)?;
        if metadata.is_dir() {
            zip.add_directory(format!("{zip_name}/"), directory_options())
                .map_err(zip_error)?;
            add_directory_to_zip(
                &path,
                base_root,
                review_db_path,
                destination,
                temp_path,
                review_db_snapshot_path,
                zip,
                file_count,
                total_files,
                progress,
            )?;
        } else if metadata.is_file() {
            add_file_to_zip(&path, &zip_name, zip)?;
            *file_count += 1;
            progress(DeckBundleProgress {
                completed: *file_count,
                total: total_files,
            });
        }
    }

    Ok(())
}

fn planned_deck_bundle_file_count(
    deck_path: &Path,
    destination: &Path,
    temp_path: &Path,
    review_database: Option<ReviewDatabaseSnapshot<'_>>,
) -> io::Result<usize> {
    let mut total = 1 + usize::from(review_database.is_some());
    let sidecar_dir = sidecar_dir_for_deck_path(deck_path);
    if sidecar_dir.is_dir() {
        total += count_bundle_sidecar_files(
            &sidecar_dir,
            &review_db_path_for_deck(deck_path),
            destination,
            temp_path,
            review_database.map(|database| database.snapshot_path),
        )?;
    }
    Ok(total)
}

fn count_bundle_sidecar_files(
    directory: &Path,
    review_db_path: &Path,
    destination: &Path,
    temp_path: &Path,
    review_db_snapshot_path: Option<&Path>,
) -> io::Result<usize> {
    let mut total = 0;
    for entry in fs::read_dir(directory)
        .map_err(|error| path_io_error("could not read sidecar directory", directory, error))?
    {
        let entry = entry.map_err(|error| {
            path_io_error("could not read sidecar directory entry", directory, error)
        })?;
        let path = entry.path();
        if same_path(&path, destination)
            || same_path(&path, temp_path)
            || same_path(&path, review_db_path)
            || review_db_snapshot_path.is_some_and(|snapshot_path| same_path(&path, snapshot_path))
        {
            continue;
        }
        let metadata = entry
            .metadata()
            .map_err(|error| path_io_error("could not inspect sidecar entry", &path, error))?;
        if metadata.is_dir() {
            total += count_bundle_sidecar_files(
                &path,
                review_db_path,
                destination,
                temp_path,
                review_db_snapshot_path,
            )?;
        } else if metadata.is_file() {
            total += 1;
        }
    }
    Ok(total)
}

fn add_file_to_zip(path: &Path, zip_name: &str, zip: &mut ZipWriter<File>) -> io::Result<()> {
    zip.start_file(zip_name, file_options())
        .map_err(zip_error)?;
    let mut file = File::open(path)
        .map_err(|error| path_io_error("could not open bundle file", path, error))?;
    let mut buffer = [0_u8; 64 * 1024];
    loop {
        let read = file
            .read(&mut buffer)
            .map_err(|error| path_io_error("could not read bundle file", path, error))?;
        if read == 0 {
            break;
        }
        zip.write_all(&buffer[..read]).map_err(|error| {
            path_io_error(
                "could not write file into deck bundle",
                Path::new(zip_name),
                error,
            )
        })?;
    }
    Ok(())
}

fn safe_archive_path(raw_name: &str, enclosed_name: Option<PathBuf>) -> io::Result<PathBuf> {
    if raw_name.is_empty() {
        return Err(invalid_bundle("archive entry names must not be empty"));
    }
    if raw_name.contains('\\') {
        return Err(invalid_bundle(format!(
            "archive entry uses a backslash path separator: {raw_name}"
        )));
    }
    let Some(path) = enclosed_name else {
        return Err(invalid_bundle(format!(
            "archive entry has an unsafe path: {raw_name}"
        )));
    };
    if path.as_os_str().is_empty() {
        return Err(invalid_bundle("archive entry names must not be empty"));
    }
    for component in path.components() {
        match component {
            Component::Normal(component) => {
                let Some(component) = component.to_str() else {
                    return Err(invalid_bundle(format!(
                        "archive entry is not valid Unicode: {raw_name}"
                    )));
                };
                if component.is_empty()
                    || component.chars().any(|character| {
                        character.is_control()
                            || matches!(
                                character,
                                '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
                            )
                    })
                {
                    return Err(invalid_bundle(format!(
                        "archive entry contains an unsupported path component: {raw_name}"
                    )));
                }
            }
            _ => {
                return Err(invalid_bundle(format!(
                    "archive entry has an unsafe path: {raw_name}"
                )));
            }
        }
    }
    Ok(path)
}

fn validate_restore_entry(
    entry: &DeckBundleEntry,
    deck_relative_path: &Path,
    deck_stem: &str,
    expected_review_db_name: &str,
) -> io::Result<()> {
    if entry.relative_path == deck_relative_path {
        if entry.is_dir {
            return Err(invalid_bundle("the deck entry is a directory"));
        }
        return Ok(());
    }
    if entry.relative_path == Path::new(expected_review_db_name) {
        if entry.is_dir {
            return Err(invalid_bundle("the review database entry is a directory"));
        }
        return Ok(());
    }
    if is_sidecar_path(&entry.relative_path, deck_stem) {
        if entry.relative_path.components().count() == 1 && !entry.is_dir {
            return Err(invalid_bundle(format!(
                "sidecar root must be a directory: {}",
                entry.relative_path.display()
            )));
        }
        return Ok(());
    }
    Err(invalid_bundle(format!(
        "unexpected archive entry: {}",
        entry.relative_path.display()
    )))
}

fn is_root_deck_path(path: &Path) -> bool {
    path.parent()
        .is_none_or(|parent| parent.as_os_str().is_empty())
        && path
            .extension()
            .and_then(|extension| extension.to_str())
            .is_some_and(|extension| extension.eq_ignore_ascii_case("cosimo-deck"))
}

fn is_sidecar_path(path: &Path, deck_stem: &str) -> bool {
    path.components()
        .next()
        .and_then(|component| match component {
            Component::Normal(component) => component.to_str(),
            _ => None,
        })
        == Some(deck_stem)
}

fn is_audio_entry(path: &Path, deck_stem: &str) -> bool {
    is_sidecar_path(path, deck_stem)
        && path
            .extension()
            .and_then(|extension| extension.to_str())
            .is_some_and(is_audio_extension)
}

fn is_audio_extension(extension: &str) -> bool {
    extension.eq_ignore_ascii_case("mp3")
        || extension.eq_ignore_ascii_case("opus")
        || extension.eq_ignore_ascii_case("wav")
}

fn single_component_string(path: &Path) -> io::Result<String> {
    if path.components().count() != 1 {
        return Err(invalid_bundle(format!(
            "expected a root archive entry, got {}",
            path.display()
        )));
    }
    path.file_name()
        .and_then(|name| name.to_str())
        .map(str::to_owned)
        .ok_or_else(|| {
            invalid_bundle(format!(
                "archive entry is not valid Unicode: {}",
                path.display()
            ))
        })
}

fn read_zip_entry_to_string(archive: &mut ZipArchive<File>, index: usize) -> io::Result<String> {
    let mut entry = archive.by_index(index).map_err(zip_error)?;
    let mut text = String::new();
    entry.read_to_string(&mut text)?;
    Ok(text)
}

fn create_restore_staging_dir(decks_dir: &Path) -> io::Result<PathBuf> {
    let process_id = std::process::id();
    for suffix in 0..10_000 {
        let candidate = decks_dir.join(format!(".cosimo-restore-{process_id}-{suffix}.tmp"));
        match fs::create_dir(&candidate) {
            Ok(()) => return Ok(candidate),
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
            Err(error) => {
                return Err(path_io_error(
                    "could not create temporary restore directory",
                    &candidate,
                    error,
                ));
            }
        }
    }
    Err(io::Error::new(
        io::ErrorKind::AlreadyExists,
        "could not create a unique temporary restore directory",
    ))
}

fn zip_name_from_relative_path(path: &Path) -> io::Result<String> {
    let mut parts = Vec::new();
    for component in path.components() {
        match component {
            Component::Normal(component) => {
                let Some(component) = component.to_str() else {
                    return Err(invalid_bundle(format!(
                        "archive entry is not valid Unicode: {}",
                        path.display()
                    )));
                };
                parts.push(component);
            }
            _ => {
                return Err(invalid_bundle(format!(
                    "archive entry has an unsafe path: {}",
                    path.display()
                )));
            }
        }
    }
    Ok(parts.join("/"))
}

fn invalid_bundle(message: impl Into<String>) -> io::Error {
    io::Error::new(io::ErrorKind::InvalidData, message.into())
}

fn record_deck_bundle_backup_index(deck_path: &Path, backup_path: &Path) -> io::Result<()> {
    let backup_dir = backup_path.parent().unwrap_or_else(|| Path::new("."));
    let index_path = deck_bundle_backup_index_path(backup_dir);
    let backup_file_name = backup_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("unknown-deck-bundle-backup");
    let original_path = fs::canonicalize(deck_path).unwrap_or_else(|_| deck_path.to_path_buf());
    let existing = match fs::read_to_string(&index_path) {
        Ok(existing) => existing,
        Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
        Err(error) => {
            return Err(path_io_error(
                "could not read deck bundle backup index",
                &index_path,
                error,
            ));
        }
    };
    let new_entry = format!("{backup_file_name}\t{}\n", original_path.display());
    write_text_file_durably(index_path, &(existing + &new_entry))
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct AutomaticDeckBundleBackupIndexEntry {
    backup_file_name: String,
    timestamp_unix_seconds: i64,
    original_path: String,
}

fn record_automatic_deck_bundle_backup_index(
    deck_path: &Path,
    backup_path: &Path,
    timestamp_unix_seconds: i64,
) -> io::Result<()> {
    let backup_dir = backup_path.parent().unwrap_or_else(|| Path::new("."));
    let index_path = automatic_deck_bundle_backup_index_path(backup_dir);
    let existing = match fs::read_to_string(&index_path) {
        Ok(existing) => existing,
        Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
        Err(error) => {
            return Err(path_io_error(
                "could not read automatic full-deck backup index",
                &index_path,
                error,
            ));
        }
    };
    let original_path = canonical_path_text(deck_path);
    let backup_file_name = backup_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("unknown-automatic-full-deck-backup")
        .to_string();

    let mut entries: BTreeMap<String, AutomaticDeckBundleBackupIndexEntry> =
        parse_automatic_deck_bundle_backup_index(&existing)
            .into_iter()
            .map(|entry| (entry.original_path.clone(), entry))
            .collect();
    entries.insert(
        original_path.clone(),
        AutomaticDeckBundleBackupIndexEntry {
            backup_file_name,
            timestamp_unix_seconds,
            original_path,
        },
    );

    let mut index_text = String::new();
    for entry in entries.values() {
        index_text.push_str(&format!(
            "{}\t{}\t{}\n",
            entry.backup_file_name, entry.timestamp_unix_seconds, entry.original_path
        ));
    }
    write_text_file_durably(index_path, &index_text)
}

fn parse_automatic_deck_bundle_backup_index(
    index_text: &str,
) -> Vec<AutomaticDeckBundleBackupIndexEntry> {
    index_text
        .lines()
        .filter_map(|line| {
            let mut fields = line.splitn(3, '\t');
            let backup_file_name = fields.next()?.to_string();
            let timestamp_unix_seconds = fields.next()?.parse::<i64>().ok()?;
            let original_path = fields.next()?.to_string();
            Some(AutomaticDeckBundleBackupIndexEntry {
                backup_file_name,
                timestamp_unix_seconds,
                original_path,
            })
        })
        .collect()
}

fn automatic_deck_bundle_indexed_backup_path(
    backup_dir: &Path,
    backup_file_name: &str,
) -> Option<PathBuf> {
    let path = Path::new(backup_file_name);
    let mut components = path.components();
    match (components.next(), components.next()) {
        (Some(Component::Normal(_)), None) => Some(backup_dir.join(path)),
        _ => None,
    }
}

fn is_fixed_automatic_deck_bundle_backup_file_name(file_name: &str) -> bool {
    file_name.starts_with("automatic-full-deck-") && file_name.ends_with(".zip")
}

fn sidecar_dir_for_deck_path(deck_path: &Path) -> PathBuf {
    let parent = deck_path.parent().unwrap_or_else(|| Path::new("."));
    let stem = deck_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .filter(|stem| !stem.is_empty())
        .unwrap_or("deck");
    parent.join(stem)
}

fn canonical_path_text(path: &Path) -> String {
    fs::canonicalize(path)
        .unwrap_or_else(|_| path.to_path_buf())
        .display()
        .to_string()
}

fn unique_bundle_path(backup_dir: &Path, deck_stem: &str, timestamp: &str) -> PathBuf {
    let first = backup_dir.join(format!("{deck_stem}-{timestamp}.zip"));
    if !first.exists() {
        return first;
    }
    for suffix in 2..10_000 {
        let candidate = backup_dir.join(format!("{deck_stem}-{timestamp}-{suffix}.zip"));
        if !candidate.exists() {
            return candidate;
        }
    }
    backup_dir.join(format!("{deck_stem}-{timestamp}-10000.zip"))
}

fn sanitized_deck_stem(deck_path: &Path) -> String {
    let stem = deck_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("deck")
        .trim();
    let sanitized: String = stem
        .chars()
        .map(|character| {
            if character.is_control()
                || matches!(
                    character,
                    '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
                )
            {
                '_'
            } else {
                character
            }
        })
        .collect();
    if sanitized.is_empty() {
        "deck".to_string()
    } else {
        sanitized
    }
}

fn zip_path_name(path: &Path, base_root: &Path) -> io::Result<String> {
    let relative = path.strip_prefix(base_root).map_err(io::Error::other)?;
    Ok(relative
        .components()
        .map(|component| component.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/"))
}

fn same_path(left: &Path, right: &Path) -> bool {
    if left == right {
        return true;
    }
    match (fs::canonicalize(left), fs::canonicalize(right)) {
        (Ok(left), Ok(right)) => left == right,
        _ => false,
    }
}

fn file_options() -> SimpleFileOptions {
    SimpleFileOptions::default()
        .compression_method(CompressionMethod::Deflated)
        .compression_level(Some(DECK_BUNDLE_DEFLATE_LEVEL))
        .unix_permissions(0o644)
}

fn directory_options() -> SimpleFileOptions {
    SimpleFileOptions::default()
        .compression_method(CompressionMethod::Stored)
        .unix_permissions(0o755)
}

fn zip_error(error: zip::result::ZipError) -> io::Error {
    io::Error::other(error)
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;
    use std::collections::BTreeMap;
    use std::process;
    use std::time::{SystemTime, UNIX_EPOCH};
    use zip::ZipArchive;

    fn unique_temp_dir(prefix: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "cosimo-deck-bundle-{prefix}-{}-{unique}",
            process::id()
        ));
        fs::create_dir_all(&path).unwrap();
        path
    }

    fn zip_names(path: &Path) -> Vec<String> {
        let file = File::open(path).unwrap();
        let mut archive = ZipArchive::new(file).unwrap();
        let mut names = Vec::new();
        for index in 0..archive.len() {
            names.push(archive.by_index(index).unwrap().name().to_string());
        }
        names.sort();
        names
    }

    fn zip_entry_bytes(path: &Path, name: &str) -> Vec<u8> {
        let file = File::open(path).unwrap();
        let mut archive = ZipArchive::new(file).unwrap();
        let mut entry = archive.by_name(name).unwrap();
        let mut bytes = Vec::new();
        entry.read_to_end(&mut bytes).unwrap();
        bytes
    }

    fn valid_deck_text(response: &str) -> String {
        format!("deck_version=2\n:::\nprompt\n---\n{response}\n=1=\n")
    }

    #[test]
    fn bundle_includes_deck_and_sidecar_files_but_not_review_database() {
        let temp_dir = unique_temp_dir("contents");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(sidecar_dir.join("nested")).unwrap();
        fs::write(&deck_path, "deck_version=2\n:::\n=1= prompt\nresponse\n").unwrap();
        fs::write(sidecar_dir.join("1.prompt.mp3"), "mp3 prompt audio").unwrap();
        fs::write(sidecar_dir.join("1.prompt.opus"), "compressed prompt audio").unwrap();
        fs::write(sidecar_dir.join("1.prompt.wav"), "prompt audio").unwrap();
        fs::write(
            sidecar_dir.join("nested").join("audio-manifest.tsv"),
            "manifest",
        )
        .unwrap();
        fs::write(review_db_path_for_deck(&deck_path), "review db").unwrap();

        let bundle_path = temp_dir.join("bundle.zip");
        let summary = write_deck_bundle(&deck_path, &bundle_path).unwrap();

        assert_eq!(summary.file_count, 5);
        assert_eq!(
            zip_names(&bundle_path),
            vec![
                "Indonesian.cosimo-deck",
                "Indonesian/1.prompt.mp3",
                "Indonesian/1.prompt.opus",
                "Indonesian/1.prompt.wav",
                "Indonesian/nested/",
                "Indonesian/nested/audio-manifest.tsv",
            ]
        );
    }

    #[test]
    fn bundle_with_study_history_includes_review_database_snapshot() {
        let temp_dir = unique_temp_dir("study-history");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let review_db_path = review_db_path_for_deck(&deck_path);
        fs::write(&deck_path, "deck").unwrap();
        let store = SqliteReviewStore::open(&review_db_path).unwrap();

        let bundle_path = temp_dir.join("bundle-with-history.zip");
        let summary = write_deck_bundle_with_review_database(
            &deck_path,
            &review_db_path,
            &store,
            &bundle_path,
        )
        .unwrap();

        assert_eq!(summary.file_count, 2);
        assert_eq!(
            zip_names(&bundle_path),
            vec!["deck.cosimo-deck", "deck.reviews.sqlite3"]
        );
    }

    #[test]
    fn content_only_derivation_removes_review_database_snapshot() {
        let temp_dir = unique_temp_dir("content-only-derive");
        let source_dir = temp_dir.join("source");
        fs::create_dir_all(source_dir.join("Indonesian")).unwrap();
        let deck_path = source_dir.join("Indonesian.cosimo-deck");
        let review_db_path = review_db_path_for_deck(&deck_path);
        fs::write(&deck_path, valid_deck_text("jawaban")).unwrap();
        fs::write(
            source_dir.join("Indonesian").join("1.prompt.wav"),
            "prompt audio",
        )
        .unwrap();
        let store = SqliteReviewStore::open(&review_db_path).unwrap();
        let study_history_bundle = temp_dir.join("with-history.zip");
        write_deck_bundle_with_review_database(
            &deck_path,
            &review_db_path,
            &store,
            &study_history_bundle,
        )
        .unwrap();

        let content_only_bundle = temp_dir.join("content-only.zip");
        let summary =
            write_content_only_deck_bundle_from_bundle(&study_history_bundle, &content_only_bundle)
                .unwrap();

        assert_eq!(summary.file_count, 2);
        assert_eq!(
            zip_names(&content_only_bundle),
            vec!["Indonesian.cosimo-deck", "Indonesian/1.prompt.wav"]
        );
        let preview = inspect_deck_bundle_restore(&content_only_bundle, &temp_dir).unwrap();
        assert!(!preview.has_review_database);
        assert_eq!(preview.audio_file_count, 1);
    }

    #[test]
    fn content_only_derivation_preserves_existing_content_only_bundle() {
        let temp_dir = unique_temp_dir("content-only-preserve");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        fs::create_dir_all(temp_dir.join("deck")).unwrap();
        fs::write(&deck_path, valid_deck_text("response")).unwrap();
        fs::write(temp_dir.join("deck").join("1.response.wav"), "audio").unwrap();
        let source_bundle = temp_dir.join("source.zip");
        write_deck_bundle(&deck_path, &source_bundle).unwrap();

        let derived_bundle = temp_dir.join("derived.zip");
        let summary =
            write_content_only_deck_bundle_from_bundle(&source_bundle, &derived_bundle).unwrap();

        assert_eq!(summary.file_count, 2);
        assert_eq!(
            zip_names(&derived_bundle),
            vec!["deck.cosimo-deck", "deck/1.response.wav"]
        );
    }

    #[test]
    fn content_only_derivation_rejects_replacing_source_in_place() {
        let temp_dir = unique_temp_dir("content-only-in-place");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        fs::write(&deck_path, valid_deck_text("response")).unwrap();
        let source_bundle = temp_dir.join("source.zip");
        write_deck_bundle(&deck_path, &source_bundle).unwrap();

        let error =
            write_content_only_deck_bundle_from_bundle(&source_bundle, &source_bundle).unwrap_err();

        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
    }

    proptest! {
        #![proptest_config(ProptestConfig {
            cases: 32,
            failure_persistence: None,
            ..ProptestConfig::default()
        })]

        #[test]
        fn content_only_derivation_preserves_generated_sidecar_files_and_removes_review_database(
            sidecar_files in prop::collection::vec(
                (
                    "[a-z][a-z0-9_]{0,8}",
                    prop::sample::select(vec!["wav", "txt", "opus", "json"]),
                    prop::collection::vec(any::<u8>(), 0..128),
                ),
                0..12,
            ),
        ) {
            let temp_dir = unique_temp_dir("content-only-prop");
            let deck_path = temp_dir.join("deck.cosimo-deck");
            let sidecar_dir = temp_dir.join("deck");
            fs::create_dir_all(&sidecar_dir).unwrap();
            fs::write(&deck_path, valid_deck_text("response")).unwrap();
            let review_db_path = review_db_path_for_deck(&deck_path);
            let store = SqliteReviewStore::open(&review_db_path).unwrap();

            let mut expected_files = BTreeMap::new();
            for (stem, extension, bytes) in sidecar_files {
                let file_name = format!("{stem}.{extension}");
                fs::write(sidecar_dir.join(&file_name), &bytes).unwrap();
                expected_files.insert(format!("deck/{file_name}"), bytes);
            }

            let study_history_bundle = temp_dir.join("study-history.zip");
            write_deck_bundle_with_review_database(
                &deck_path,
                &review_db_path,
                &store,
                &study_history_bundle,
            )
            .unwrap();

            let content_only_bundle = temp_dir.join("content-only.zip");
            let summary = write_content_only_deck_bundle_from_bundle(
                &study_history_bundle,
                &content_only_bundle,
            )
            .unwrap();
            let names = zip_names(&content_only_bundle);

            prop_assert!(names.contains(&"deck.cosimo-deck".to_string()));
            prop_assert!(!names.contains(&"deck.reviews.sqlite3".to_string()));
            prop_assert_eq!(summary.file_count, expected_files.len() + 1);
            for (name, bytes) in expected_files {
                prop_assert!(names.contains(&name));
                prop_assert_eq!(zip_entry_bytes(&content_only_bundle, &name), bytes);
            }
            let preview = inspect_deck_bundle_restore(&content_only_bundle, &temp_dir).unwrap();
            prop_assert!(!preview.has_review_database);
        }
    }

    #[test]
    fn bundle_skips_destination_when_saved_inside_sidecar_directory() {
        let temp_dir = unique_temp_dir("inside-sidecar");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let sidecar_dir = temp_dir.join("deck");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(&deck_path, "deck").unwrap();
        fs::write(sidecar_dir.join("1.response.wav"), "response audio").unwrap();

        let bundle_path = sidecar_dir.join("deck-bundle.zip");
        write_deck_bundle(&deck_path, &bundle_path).unwrap();

        assert_eq!(
            zip_names(&bundle_path),
            vec!["deck.cosimo-deck", "deck/1.response.wav"]
        );
    }

    #[test]
    fn bundle_backup_uses_timestamped_name_and_index() {
        let temp_dir = unique_temp_dir("backup");
        let deck_path = temp_dir.join("bad:name.cosimo-deck");
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, "deck").unwrap();

        let summary =
            write_deck_bundle_backup_with_progress(&deck_path, &backup_dir, 0, |_| {}).unwrap();

        assert_eq!(
            summary.path.file_name().and_then(|name| name.to_str()),
            Some("bad_name-1970-01-01-00-00.zip")
        );
        let index = fs::read_to_string(deck_bundle_backup_index_path(&backup_dir)).unwrap();
        assert!(index.contains("bad_name-1970-01-01-00-00.zip\t"));
        assert!(index.contains("bad:name.cosimo-deck"));
    }

    #[test]
    fn automatic_bundle_backup_includes_database_and_skips_current_backup() {
        let temp_dir = unique_temp_dir("automatic-backup");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let review_db_path = review_db_path_for_deck(&deck_path);
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, valid_deck_text("response")).unwrap();
        let store = SqliteReviewStore::open(&review_db_path).unwrap();

        let first = write_automatic_deck_bundle_backup_with_review_database_if_due(
            &deck_path,
            &review_db_path,
            &store,
            &backup_dir,
            1_000,
        )
        .unwrap();
        let first_path = match first {
            AutomaticDeckBundleBackupOutcome::Written(summary) => summary.path,
            AutomaticDeckBundleBackupOutcome::Current { .. } => {
                panic!("first automatic backup should be written")
            }
        };

        assert_eq!(
            first_path,
            automatic_deck_bundle_backup_path(&deck_path, &backup_dir)
        );
        assert_eq!(
            zip_names(&first_path),
            vec!["deck.cosimo-deck", "deck.reviews.sqlite3"]
        );
        let index =
            fs::read_to_string(automatic_deck_bundle_backup_index_path(&backup_dir)).unwrap();
        assert!(index.contains("\t1000\t"));
        assert!(index.contains("deck.cosimo-deck"));

        let second = write_automatic_deck_bundle_backup_with_review_database_if_due(
            &deck_path,
            &review_db_path,
            &store,
            &backup_dir,
            1_000 + 60 * 60,
        )
        .unwrap();
        assert_eq!(
            second,
            AutomaticDeckBundleBackupOutcome::Current { path: first_path }
        );
    }

    #[test]
    fn deck_bundle_backup_index_read_failure_reports_index_path() {
        let temp_dir = unique_temp_dir("bundle-index-read-error");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let backup_dir = temp_dir.join("backups");
        let index_path = deck_bundle_backup_index_path(&backup_dir);
        fs::create_dir_all(&index_path).unwrap();
        fs::write(&deck_path, valid_deck_text("response")).unwrap();

        let error =
            write_deck_bundle_backup_with_progress(&deck_path, &backup_dir, 0, |_| {}).unwrap_err();

        assert!(error.to_string().contains("deck bundle backup index"));
        assert!(
            error
                .to_string()
                .contains(&index_path.display().to_string())
        );
    }

    #[test]
    fn automatic_bundle_backup_index_read_failure_reports_index_path() {
        let temp_dir = unique_temp_dir("automatic-index-read-error");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let review_db_path = review_db_path_for_deck(&deck_path);
        let backup_dir = temp_dir.join("backups");
        let index_path = automatic_deck_bundle_backup_index_path(&backup_dir);
        fs::create_dir_all(&index_path).unwrap();
        fs::write(&deck_path, valid_deck_text("response")).unwrap();
        let store = SqliteReviewStore::open(&review_db_path).unwrap();

        let error = write_automatic_deck_bundle_backup_with_review_database_if_due(
            &deck_path,
            &review_db_path,
            &store,
            &backup_dir,
            1_000,
        )
        .unwrap_err();

        assert!(
            error
                .to_string()
                .contains("automatic full-deck backup index")
        );
        assert!(
            error
                .to_string()
                .contains(&index_path.display().to_string())
        );
    }

    #[test]
    fn automatic_bundle_backup_is_due_after_twenty_four_hours_per_deck() {
        let temp_dir = unique_temp_dir("automatic-backup-due");
        let backup_dir = temp_dir.join("backups");
        let first_deck_path = temp_dir.join("deck.cosimo-deck");
        let second_deck_path = temp_dir.join("other").join("deck.cosimo-deck");
        fs::create_dir_all(second_deck_path.parent().unwrap()).unwrap();
        fs::write(&first_deck_path, valid_deck_text("first")).unwrap();
        fs::write(&second_deck_path, valid_deck_text("second")).unwrap();
        let first_review_db_path = review_db_path_for_deck(&first_deck_path);
        let second_review_db_path = review_db_path_for_deck(&second_deck_path);
        let first_store = SqliteReviewStore::open(&first_review_db_path).unwrap();
        let second_store = SqliteReviewStore::open(&second_review_db_path).unwrap();

        write_automatic_deck_bundle_backup_with_review_database_if_due(
            &first_deck_path,
            &first_review_db_path,
            &first_store,
            &backup_dir,
            1_000,
        )
        .unwrap();
        let second = write_automatic_deck_bundle_backup_with_review_database_if_due(
            &second_deck_path,
            &second_review_db_path,
            &second_store,
            &backup_dir,
            1_000 + 60,
        )
        .unwrap();
        assert!(matches!(
            second,
            AutomaticDeckBundleBackupOutcome::Written(_)
        ));

        let first_after_interval = write_automatic_deck_bundle_backup_with_review_database_if_due(
            &first_deck_path,
            &first_review_db_path,
            &first_store,
            &backup_dir,
            1_000 + 24 * 60 * 60,
        )
        .unwrap();
        assert!(matches!(
            first_after_interval,
            AutomaticDeckBundleBackupOutcome::Written(_)
        ));
        let index =
            fs::read_to_string(automatic_deck_bundle_backup_index_path(&backup_dir)).unwrap();
        assert_eq!(index.lines().count(), 2);
        assert!(index.contains("\t87400\t"));
    }

    #[test]
    fn inspect_restore_preview_counts_cards_audio_and_review_database() {
        let temp_dir = unique_temp_dir("restore-preview");
        let source_dir = temp_dir.join("source");
        let decks_dir = temp_dir.join("decks");
        fs::create_dir_all(&source_dir).unwrap();
        fs::create_dir_all(&decks_dir).unwrap();
        let deck_path = source_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = source_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(&deck_path, valid_deck_text("jawaban")).unwrap();
        fs::write(sidecar_dir.join("1.prompt.opus"), "prompt audio").unwrap();
        fs::write(sidecar_dir.join("1.prompt.wav"), "prompt audio").unwrap();
        let review_db_path = review_db_path_for_deck(&deck_path);
        let store = SqliteReviewStore::open(&review_db_path).unwrap();
        let bundle_path = temp_dir.join("bundle.zip");
        write_deck_bundle_with_review_database(&deck_path, &review_db_path, &store, &bundle_path)
            .unwrap();

        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();

        assert_eq!(preview.deck_file_name, "Indonesian.cosimo-deck");
        assert_eq!(
            preview.target_deck_path,
            decks_dir.join("Indonesian.cosimo-deck")
        );
        assert_eq!(preview.card_count, 1);
        assert_eq!(preview.audio_file_count, 2);
        assert_eq!(preview.file_count, 4);
        assert!(preview.has_review_database);
        assert!(!preview.overwrites_existing_files());
    }

    #[test]
    fn restore_bundle_reports_decks_directory_creation_path() {
        let temp_dir = unique_temp_dir("restore-decks-dir-error");
        let source_dir = temp_dir.join("source");
        fs::create_dir_all(&source_dir).unwrap();
        let deck_path = source_dir.join("deck.cosimo-deck");
        fs::write(&deck_path, valid_deck_text("response")).unwrap();
        let bundle_path = temp_dir.join("bundle.zip");
        write_deck_bundle(&deck_path, &bundle_path).unwrap();
        let not_a_directory = temp_dir.join("not-a-directory");
        fs::write(&not_a_directory, "file").unwrap();

        let preview = inspect_deck_bundle_restore(&bundle_path, &not_a_directory).unwrap();
        let error = restore_deck_bundle_from_preview(
            &bundle_path,
            &not_a_directory,
            &temp_dir.join("backups"),
            0,
            &preview,
        )
        .unwrap_err();

        assert!(
            error
                .to_string()
                .contains("could not create decks directory")
        );
        assert!(
            error
                .to_string()
                .contains(&not_a_directory.display().to_string())
        );
    }

    #[test]
    fn restore_bundle_replaces_target_and_backs_up_existing_deck_with_database() {
        let temp_dir = unique_temp_dir("restore-install");
        let source_dir = temp_dir.join("source");
        let decks_dir = temp_dir.join("decks");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(source_dir.join("Indonesian")).unwrap();
        fs::create_dir_all(decks_dir.join("Indonesian")).unwrap();

        let source_deck_path = source_dir.join("Indonesian.cosimo-deck");
        fs::write(&source_deck_path, valid_deck_text("restored response")).unwrap();
        fs::write(
            source_dir.join("Indonesian").join("1.response.wav"),
            "restored audio",
        )
        .unwrap();
        let source_review_db_path = review_db_path_for_deck(&source_deck_path);
        let source_store = SqliteReviewStore::open(&source_review_db_path).unwrap();
        let bundle_path = temp_dir.join("restore.zip");
        write_deck_bundle_with_review_database(
            &source_deck_path,
            &source_review_db_path,
            &source_store,
            &bundle_path,
        )
        .unwrap();

        let target_deck_path = decks_dir.join("Indonesian.cosimo-deck");
        fs::write(&target_deck_path, valid_deck_text("old response")).unwrap();
        fs::write(
            decks_dir.join("Indonesian").join("1.response.wav"),
            "old audio",
        )
        .unwrap();
        let target_review_db_path = review_db_path_for_deck(&target_deck_path);
        let target_store = SqliteReviewStore::open(&target_review_db_path).unwrap();
        drop(target_store);

        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let summary =
            restore_deck_bundle_from_preview(&bundle_path, &decks_dir, &backup_dir, 0, &preview)
                .unwrap();

        assert_eq!(summary.preview.target_deck_path, target_deck_path);
        assert!(summary.backup_path.is_some());
        assert!(
            fs::read_to_string(&target_deck_path)
                .unwrap()
                .contains("restored response")
        );
        assert_eq!(
            fs::read_to_string(decks_dir.join("Indonesian").join("1.response.wav")).unwrap(),
            "restored audio"
        );
        SqliteReviewStore::open(review_db_path_for_deck(&target_deck_path)).unwrap();
        let backup_names = zip_names(&summary.backup_path.unwrap());
        assert!(backup_names.contains(&"Indonesian.cosimo-deck".to_string()));
        assert!(backup_names.contains(&"Indonesian/1.response.wav".to_string()));
        assert!(backup_names.contains(&"Indonesian.reviews.sqlite3".to_string()));
    }

    #[test]
    fn restore_backup_counts_as_current_automatic_full_deck_backup() {
        let temp_dir = unique_temp_dir("restore-counts-as-automatic-backup");
        let source_dir = temp_dir.join("source");
        let decks_dir = temp_dir.join("decks");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(&source_dir).unwrap();
        fs::create_dir_all(&decks_dir).unwrap();

        let source_deck_path = source_dir.join("deck.cosimo-deck");
        fs::write(&source_deck_path, valid_deck_text("restored response")).unwrap();
        let source_review_db_path = review_db_path_for_deck(&source_deck_path);
        let source_store = SqliteReviewStore::open(&source_review_db_path).unwrap();
        let bundle_path = temp_dir.join("restore.zip");
        write_deck_bundle_with_review_database(
            &source_deck_path,
            &source_review_db_path,
            &source_store,
            &bundle_path,
        )
        .unwrap();

        let target_deck_path = decks_dir.join("deck.cosimo-deck");
        fs::write(&target_deck_path, valid_deck_text("old response")).unwrap();
        let target_review_db_path = review_db_path_for_deck(&target_deck_path);
        let target_store = SqliteReviewStore::open(&target_review_db_path).unwrap();
        drop(target_store);

        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let summary = restore_deck_bundle_from_preview(
            &bundle_path,
            &decks_dir,
            &backup_dir,
            1_000,
            &preview,
        )
        .unwrap();
        let restore_backup_path = summary.backup_path.unwrap();
        let restored_store = SqliteReviewStore::open(&target_review_db_path).unwrap();

        let automatic = write_automatic_deck_bundle_backup_with_review_database_if_due(
            &target_deck_path,
            &target_review_db_path,
            &restored_store,
            &backup_dir,
            1_000 + 60,
        )
        .unwrap();

        assert_eq!(
            automatic,
            AutomaticDeckBundleBackupOutcome::Current {
                path: restore_backup_path
            }
        );
        assert!(!automatic_deck_bundle_backup_path(&target_deck_path, &backup_dir).exists());
    }

    #[test]
    fn pruning_automatic_bundle_backups_keeps_latest_indexed_backup_per_deck() {
        let temp_dir = unique_temp_dir("prune-automatic-indexed-backups");
        let backup_dir = temp_dir.join("backups");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        fs::create_dir_all(&backup_dir).unwrap();
        fs::write(&deck_path, valid_deck_text("response")).unwrap();
        let old_backup_path = backup_dir.join("deck-old.zip");
        let new_backup_path = backup_dir.join("deck-new.zip");
        fs::write(&old_backup_path, "old backup").unwrap();
        fs::write(&new_backup_path, "new backup").unwrap();
        let original_path = canonical_path_text(&deck_path);
        fs::write(
            automatic_deck_bundle_backup_index_path(&backup_dir),
            format!("deck-old.zip\t1000\t{original_path}\ndeck-new.zip\t2000\t{original_path}\n"),
        )
        .unwrap();

        let summary = prune_obsolete_automatic_deck_bundle_backups(&backup_dir).unwrap();

        assert_eq!(
            summary,
            PruneAutomaticDeckBundleBackupsSummary {
                removed_count: 1,
                kept_count: 1
            }
        );
        assert!(!old_backup_path.exists());
        assert!(new_backup_path.exists());
        let index =
            fs::read_to_string(automatic_deck_bundle_backup_index_path(&backup_dir)).unwrap();
        assert!(!index.contains("deck-old.zip"));
        assert!(index.contains("deck-new.zip\t2000\t"));
    }

    #[test]
    fn pruning_automatic_bundle_backups_removes_unindexed_fixed_daily_file() {
        let temp_dir = unique_temp_dir("prune-automatic-fixed-backup");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let review_db_path = review_db_path_for_deck(&deck_path);
        let backup_dir = temp_dir.join("backups");
        fs::write(&deck_path, valid_deck_text("response")).unwrap();
        let store = SqliteReviewStore::open(&review_db_path).unwrap();
        write_automatic_deck_bundle_backup_with_review_database_if_due(
            &deck_path,
            &review_db_path,
            &store,
            &backup_dir,
            1_000,
        )
        .unwrap();
        let fixed_backup_path = automatic_deck_bundle_backup_path(&deck_path, &backup_dir);
        assert!(fixed_backup_path.exists());
        let retained_backup_path = deck_bundle_backup_path(&deck_path, &backup_dir, 2_000);
        fs::write(&retained_backup_path, "retained backup").unwrap();
        record_automatic_deck_bundle_backup_index(&deck_path, &retained_backup_path, 2_000)
            .unwrap();

        let summary = prune_obsolete_automatic_deck_bundle_backups(&backup_dir).unwrap();

        assert_eq!(
            summary,
            PruneAutomaticDeckBundleBackupsSummary {
                removed_count: 1,
                kept_count: 1
            }
        );
        assert!(!fixed_backup_path.exists());
        assert!(retained_backup_path.exists());
        let index =
            fs::read_to_string(automatic_deck_bundle_backup_index_path(&backup_dir)).unwrap();
        assert!(
            index.contains(
                retained_backup_path
                    .file_name()
                    .and_then(|name| name.to_str())
                    .unwrap()
            )
        );
        assert!(!index.contains("automatic-full-deck-"));
    }

    #[test]
    fn content_only_restore_preserves_existing_review_database() {
        let temp_dir = unique_temp_dir("content-only-restore-preserves-db");
        let source_dir = temp_dir.join("source");
        let decks_dir = temp_dir.join("decks");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(&source_dir).unwrap();
        fs::create_dir_all(&decks_dir).unwrap();

        let source_deck_path = source_dir.join("Indonesian.cosimo-deck");
        fs::write(&source_deck_path, valid_deck_text("maintainer correction")).unwrap();
        let bundle_path = temp_dir.join("content-only.zip");
        write_deck_bundle(&source_deck_path, &bundle_path).unwrap();

        let target_deck_path = decks_dir.join("Indonesian.cosimo-deck");
        fs::write(&target_deck_path, valid_deck_text("learner version")).unwrap();
        let target_review_db_path = review_db_path_for_deck(&target_deck_path);
        let store = SqliteReviewStore::open(&target_review_db_path).unwrap();
        let datum = cosimo::Datum::new("prompt", "learner version")
            .unwrap()
            .with_card_id(1);
        store
            .record_review_at(
                &datum,
                cosimo::Review::new(
                    cosimo::PromptGuess::ProbablyDontKnow,
                    cosimo::RecallFeedback::Good,
                ),
                1_800_000_000,
            )
            .unwrap();
        drop(store);

        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        assert!(!preview.has_review_database);

        restore_deck_bundle_from_preview(&bundle_path, &decks_dir, &backup_dir, 0, &preview)
            .unwrap();

        assert!(
            fs::read_to_string(&target_deck_path)
                .unwrap()
                .contains("maintainer correction")
        );
        let reopened_store = SqliteReviewStore::open(&target_review_db_path).unwrap();
        let reviews = reopened_store
            .reviews_for_datum_key(&cosimo::DatumKey::for_card_id(1))
            .unwrap();
        assert_eq!(reviews.len(), 1);
    }

    #[test]
    fn retargeted_restore_renames_deck_review_database_and_sidecar_files() {
        let temp_dir = unique_temp_dir("restore-retarget");
        let source_dir = temp_dir.join("source");
        let target_dir = temp_dir.join("target");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(source_dir.join("Indonesian")).unwrap();
        fs::create_dir_all(&target_dir).unwrap();

        let source_deck_path = source_dir.join("Indonesian.cosimo-deck");
        fs::write(&source_deck_path, valid_deck_text("restored response")).unwrap();
        fs::write(
            source_dir.join("Indonesian").join("1.prompt.opus"),
            "restored prompt",
        )
        .unwrap();
        let source_review_db_path = review_db_path_for_deck(&source_deck_path);
        let source_store = SqliteReviewStore::open(&source_review_db_path).unwrap();
        let bundle_path = temp_dir.join("restore.zip");
        write_deck_bundle_with_review_database(
            &source_deck_path,
            &source_review_db_path,
            &source_store,
            &bundle_path,
        )
        .unwrap();

        let original_preview = inspect_deck_bundle_restore(&bundle_path, &target_dir).unwrap();
        let target_deck_path = target_dir.join("Downloaded.cosimo-deck");
        let preview =
            retarget_deck_bundle_restore_preview(&original_preview, &target_deck_path).unwrap();
        let summary =
            restore_deck_bundle_from_preview(&bundle_path, &target_dir, &backup_dir, 0, &preview)
                .unwrap();

        assert_eq!(summary.preview.target_deck_path, target_deck_path);
        assert!(target_dir.join("Downloaded.cosimo-deck").is_file());
        assert!(target_dir.join("Downloaded.reviews.sqlite3").is_file());
        assert_eq!(
            fs::read_to_string(target_dir.join("Downloaded").join("1.prompt.opus")).unwrap(),
            "restored prompt"
        );
        assert!(!target_dir.join("Indonesian.cosimo-deck").exists());
        assert!(!target_dir.join("Indonesian").exists());
    }

    #[test]
    fn restore_refuses_to_overwrite_orphan_sidecar_files_without_a_deck_backup() {
        let temp_dir = unique_temp_dir("restore-orphan-sidecar");
        let source_dir = temp_dir.join("source");
        let decks_dir = temp_dir.join("decks");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(source_dir.join("Indonesian")).unwrap();
        fs::create_dir_all(decks_dir.join("Indonesian")).unwrap();
        let source_deck_path = source_dir.join("Indonesian.cosimo-deck");
        fs::write(&source_deck_path, valid_deck_text("restored response")).unwrap();
        fs::write(
            source_dir.join("Indonesian").join("1.response.wav"),
            "restored audio",
        )
        .unwrap();
        let bundle_path = temp_dir.join("restore.zip");
        write_deck_bundle(&source_deck_path, &bundle_path).unwrap();
        fs::write(
            decks_dir.join("Indonesian").join("1.response.wav"),
            "orphan audio",
        )
        .unwrap();

        let preview = inspect_deck_bundle_restore(&bundle_path, &decks_dir).unwrap();
        let error =
            restore_deck_bundle_from_preview(&bundle_path, &decks_dir, &backup_dir, 0, &preview)
                .unwrap_err();

        assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
        assert_eq!(
            fs::read_to_string(decks_dir.join("Indonesian").join("1.response.wav")).unwrap(),
            "orphan audio"
        );
    }

    #[test]
    fn inspect_restore_rejects_unsafe_or_unexpected_archive_entries() {
        let temp_dir = unique_temp_dir("restore-reject");
        let decks_dir = temp_dir.join("decks");
        fs::create_dir_all(&decks_dir).unwrap();
        let unsafe_bundle = temp_dir.join("unsafe.zip");
        {
            let file = File::create(&unsafe_bundle).unwrap();
            let mut zip = ZipWriter::new(file);
            zip.start_file("../deck.cosimo-deck", file_options())
                .unwrap();
            zip.write_all(valid_deck_text("response").as_bytes())
                .unwrap();
            zip.finish().unwrap();
        }
        assert_eq!(
            inspect_deck_bundle_restore(&unsafe_bundle, &decks_dir)
                .unwrap_err()
                .kind(),
            io::ErrorKind::InvalidData
        );

        let unexpected_bundle = temp_dir.join("unexpected.zip");
        {
            let file = File::create(&unexpected_bundle).unwrap();
            let mut zip = ZipWriter::new(file);
            zip.start_file("deck.cosimo-deck", file_options()).unwrap();
            zip.write_all(valid_deck_text("response").as_bytes())
                .unwrap();
            zip.start_file("notes.txt", file_options()).unwrap();
            zip.write_all(b"unexpected").unwrap();
            zip.finish().unwrap();
        }
        assert_eq!(
            inspect_deck_bundle_restore(&unexpected_bundle, &decks_dir)
                .unwrap_err()
                .kind(),
            io::ErrorKind::InvalidData
        );
    }
}
