use cosimo::SqliteReviewStore;
use std::collections::{BTreeMap, hash_map::DefaultHasher};
use std::ffi::CString;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
use std::path::{Path, PathBuf};

const BACKUP_DIR: &str = "backups";
const LOGS_DIR: &str = "logs";
const REPORTS_DIR: &str = "reports";

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum DeckBackupPolicy {
    #[default]
    Full,
    LastOnly,
    None,
}

impl DeckBackupPolicy {
    pub(crate) const ALL: &'static [Self] = &[Self::Full, Self::LastOnly, Self::None];

    pub(crate) fn from_code(code: &str) -> Option<Self> {
        match code {
            "full" => Some(Self::Full),
            "last-only" => Some(Self::LastOnly),
            "none" => Some(Self::None),
            _ => None,
        }
    }

    pub(crate) fn code(self) -> &'static str {
        match self {
            Self::Full => "full",
            Self::LastOnly => "last-only",
            Self::None => "none",
        }
    }
}

pub(crate) fn write_deck_file_durably(
    path: impl AsRef<Path>,
    backup_dir: impl AsRef<Path>,
    backup_policy: DeckBackupPolicy,
    contents: &str,
) -> io::Result<()> {
    let path = path.as_ref();
    // Deck text is user-authored data, so preserve the previous version before
    // replacing it. Review databases use SQLite's own durability mechanisms and
    // have a separate single-copy backup path.
    apply_deck_backup_policy(path, backup_dir.as_ref(), backup_policy)?;
    write_text_file_durably(path, contents)
}

pub(crate) fn apply_deck_backup_policy(
    deck_path: &Path,
    backup_dir: &Path,
    backup_policy: DeckBackupPolicy,
) -> io::Result<Option<PathBuf>> {
    match backup_policy {
        DeckBackupPolicy::Full => create_deck_backup(deck_path, backup_dir),
        DeckBackupPolicy::LastOnly => {
            let backup_path = create_deck_backup(deck_path, backup_dir)?;
            if backup_path.is_some() {
                prune_obsolete_deck_backups(backup_dir)?;
            }
            Ok(backup_path)
        }
        DeckBackupPolicy::None => Ok(None),
    }
}

fn create_deck_backup(deck_path: &Path, backup_dir: &Path) -> io::Result<Option<PathBuf>> {
    if !deck_path
        .try_exists()
        .map_err(|error| path_io_error("could not check deck file", deck_path, error))?
    {
        return Ok(None);
    }

    fs::create_dir_all(backup_dir)
        .map_err(|error| path_io_error("could not create backup directory", backup_dir, error))?;
    let mut next_number = next_deck_backup_number(backup_dir)?;

    for _ in 0..1000 {
        let backup_path = deck_backup_path(deck_path, backup_dir, next_number);
        match copy_file_exclusive(deck_path, &backup_path) {
            Ok(()) => {
                record_deck_backup_index(deck_path, &backup_path)?;
                return Ok(Some(backup_path));
            }
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
                next_number += 1;
            }
            Err(error) => return Err(error),
        }
    }

    Err(io::Error::new(
        io::ErrorKind::AlreadyExists,
        "could not allocate a deck backup file name",
    ))
}

pub(crate) fn default_backup_dir() -> PathBuf {
    portable_path(BACKUP_DIR)
}

pub(crate) fn default_logs_dir() -> PathBuf {
    portable_path(LOGS_DIR)
}

pub(crate) fn default_reports_dir() -> PathBuf {
    portable_path(REPORTS_DIR)
}

fn deck_backup_index_path(backup_dir: &Path) -> PathBuf {
    backup_dir.join("index.txt")
}

pub(crate) fn review_db_backup_index_path(backup_dir: &Path) -> PathBuf {
    backup_dir.join("review-db-index.txt")
}

pub(crate) fn backup_review_database(
    review_db_path: &Path,
    backup_dir: &Path,
    store: &SqliteReviewStore,
) -> io::Result<PathBuf> {
    fs::create_dir_all(backup_dir)
        .map_err(|error| path_io_error("could not create backup directory", backup_dir, error))?;
    let backup_path = review_db_backup_path(review_db_path, backup_dir);
    let temp_path = create_unique_temp_path(&backup_path)?;
    let backup_result = store
        .backup_to_path(&temp_path)
        .map_err(|error| io::Error::other(error.to_string()));

    match backup_result {
        Ok(()) => {
            replace_file_atomically(&temp_path, &backup_path)?;
            record_review_db_backup_index(review_db_path, &backup_path)?;
            Ok(backup_path)
        }
        Err(error) => {
            let _ = fs::remove_file(temp_path);
            Err(error)
        }
    }
}

pub(crate) fn review_db_backup_path(review_db_path: &Path, backup_dir: &Path) -> PathBuf {
    let original_path =
        fs::canonicalize(review_db_path).unwrap_or_else(|_| review_db_path.to_path_buf());
    let mut hasher = DefaultHasher::new();
    original_path.display().to_string().hash(&mut hasher);
    let path_hash = hasher.finish();
    let stem = review_db_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .filter(|stem| !stem.is_empty())
        .unwrap_or("reviews");
    backup_dir.join(format!("review-db-{path_hash:016x}-{stem}.sqlite3"))
}

fn record_review_db_backup_index(review_db_path: &Path, backup_path: &Path) -> io::Result<()> {
    let backup_dir = backup_path.parent().unwrap_or_else(|| Path::new("."));
    let backup_file_name = backup_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("unknown-review-db-backup")
        .to_string();
    let original_path =
        fs::canonicalize(review_db_path).unwrap_or_else(|_| review_db_path.to_path_buf());
    let original_path = original_path.display().to_string();
    let index_path = review_db_backup_index_path(backup_dir);
    let mut entries = BTreeMap::new();

    match fs::read_to_string(&index_path) {
        Ok(index_text) => {
            for line in index_text.lines() {
                let Some((backup_file_name, original_path)) = line.split_once('\t') else {
                    continue;
                };
                entries.insert(original_path.to_string(), backup_file_name.to_string());
            }
        }
        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
        Err(error) => {
            return Err(path_io_error(
                "could not read review database backup index",
                &index_path,
                error,
            ));
        }
    }

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

fn next_deck_backup_number(backup_dir: &Path) -> io::Result<u64> {
    if !backup_dir
        .try_exists()
        .map_err(|error| path_io_error("could not check backup directory", backup_dir, error))?
    {
        return Ok(1);
    }

    let mut highest = 0;
    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 Some(file_name) = entry.file_name().to_str().map(str::to_owned) else {
            continue;
        };
        if let Some(number) = deck_backup_number_from_name(&file_name) {
            highest = highest.max(number);
        }
    }

    Ok(highest + 1)
}

fn deck_backup_path(deck_path: &Path, backup_dir: &Path, number: u64) -> PathBuf {
    let stem = deck_backup_stem(deck_path);
    match deck_path
        .extension()
        .and_then(|extension| extension.to_str())
    {
        Some(extension) if !extension.is_empty() => {
            backup_dir.join(format!("{number:06}-{stem}.{extension}"))
        }
        _ => backup_dir.join(format!("{number:06}-{stem}")),
    }
}

fn deck_backup_number_from_name(file_name: &str) -> Option<u64> {
    let number_text = file_name.split_once('-')?.0;
    if number_text.len() == 6
        && number_text
            .chars()
            .all(|character| character.is_ascii_digit())
    {
        number_text.parse().ok()
    } else {
        None
    }
}

fn deck_backup_stem(deck_path: &Path) -> String {
    deck_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .filter(|stem| !stem.is_empty())
        .unwrap_or("deck")
        .to_string()
}

fn copy_file_exclusive(source: &Path, destination: &Path) -> io::Result<()> {
    let mut source_file = fs::File::open(source)
        .map_err(|error| path_io_error("could not open source file", source, error))?;
    let mut destination_file = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(destination)
        .map_err(|error| path_io_error("could not create backup file", destination, error))?;
    io::copy(&mut source_file, &mut destination_file)
        .map_err(|error| two_path_io_error("could not copy file", source, destination, error))?;
    destination_file
        .sync_all()
        .map_err(|error| path_io_error("could not flush backup file", destination, error))?;
    best_effort_sync_parent_directory(destination);
    Ok(())
}

#[cfg(not(windows))]
fn replace_file_atomically(source: &Path, destination: &Path) -> io::Result<()> {
    fs::rename(source, destination)?;
    best_effort_sync_parent_directory(destination);
    Ok(())
}

#[cfg(windows)]
fn replace_file_atomically(source: &Path, destination: &Path) -> io::Result<()> {
    use std::os::windows::ffi::OsStrExt;
    use windows_sys::Win32::Storage::FileSystem::{
        MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
    };

    let destination_path = destination.to_path_buf();
    let source: Vec<u16> = source
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();
    let destination: Vec<u16> = destination
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();

    // SAFETY: both paths are valid NUL-terminated UTF-16 buffers that remain
    // alive for the duration of the call. Null module handles are not involved,
    // and MoveFileExW does not retain the pointers after returning.
    let result = unsafe {
        MoveFileExW(
            source.as_ptr(),
            destination.as_ptr(),
            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
        )
    };

    if result == 0 {
        Err(io::Error::last_os_error())
    } else {
        best_effort_sync_parent_directory(&destination_path);
        Ok(())
    }
}

fn record_deck_backup_index(deck_path: &Path, backup_path: &Path) -> io::Result<()> {
    let backup_dir = backup_path.parent().unwrap_or_else(|| Path::new("."));
    let backup_file_name = backup_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("unknown-backup");
    let original_path = fs::canonicalize(deck_path).unwrap_or_else(|_| deck_path.to_path_buf());
    let index_entry = format!("{backup_file_name}\t{}\n", original_path.display());
    let index_path = deck_backup_index_path(backup_dir);
    let mut index_file = fs::OpenOptions::new()
        .append(true)
        .create(true)
        .open(&index_path)
        .map_err(|error| path_io_error("could not open deck backup index", &index_path, error))?;
    index_file
        .write_all(index_entry.as_bytes())
        .map_err(|error| path_io_error("could not write deck backup index", &index_path, error))?;
    index_file
        .sync_all()
        .map_err(|error| path_io_error("could not flush deck backup index", &index_path, error))?;
    best_effort_sync_parent_directory(&index_path);
    Ok(())
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct DeckBackupIndexEntry {
    backup_file_name: String,
    original_path: String,
    sequence_number: u64,
}

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

struct PruneDeckBackupsPlan {
    index_path: PathBuf,
    index_exists: bool,
    obsolete_entries: Vec<DeckBackupIndexEntry>,
    kept_entries: Vec<DeckBackupIndexEntry>,
}

impl PruneDeckBackupsPlan {
    fn summary(&self) -> PruneBackupsSummary {
        PruneBackupsSummary {
            removed_count: self.obsolete_entries.len(),
            kept_count: self.kept_entries.len(),
        }
    }
}

pub(crate) fn preview_obsolete_deck_backups(backup_dir: &Path) -> io::Result<PruneBackupsSummary> {
    plan_obsolete_deck_backups(backup_dir).map(|plan| plan.summary())
}

pub(crate) fn prune_obsolete_deck_backups(backup_dir: &Path) -> io::Result<PruneBackupsSummary> {
    let plan = plan_obsolete_deck_backups(backup_dir)?;
    if !plan.index_exists {
        return Ok(plan.summary());
    }
    let PruneDeckBackupsPlan {
        index_path,
        obsolete_entries,
        kept_entries,
        ..
    } = plan;

    let mut removed_count = 0;
    for entry in obsolete_entries {
        let backup_path = backup_dir.join(entry.backup_file_name);
        if backup_path
            .try_exists()
            .map_err(|error| path_io_error("could not check backup file", &backup_path, error))?
        {
            fs::remove_file(&backup_path).map_err(|error| {
                path_io_error("could not remove obsolete backup file", &backup_path, error)
            })?;
            removed_count += 1;
        }
    }

    let mut staged_entries = Vec::with_capacity(kept_entries.len());
    for (index, entry) in kept_entries.into_iter().enumerate() {
        let current_path = backup_dir.join(&entry.backup_file_name);
        let staged_name = format!(".prune-{}-{index}.tmp", std::process::id());
        let staged_path = backup_dir.join(&staged_name);
        fs::rename(&current_path, &staged_path).map_err(|error| {
            two_path_io_error(
                "could not stage backup while renumbering",
                &current_path,
                &staged_path,
                error,
            )
        })?;
        staged_entries.push((entry, staged_path));
    }

    let mut rewritten_index = String::new();
    for (index, (entry, staged_path)) in staged_entries.into_iter().enumerate() {
        let new_sequence_number = index as u64 + 1;
        let new_backup_path = deck_backup_path(
            Path::new(&entry.original_path),
            backup_dir,
            new_sequence_number,
        );
        fs::rename(&staged_path, &new_backup_path).map_err(|error| {
            two_path_io_error(
                "could not rename staged backup",
                &staged_path,
                &new_backup_path,
                error,
            )
        })?;
        let new_backup_file_name = new_backup_path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("unknown-backup");
        rewritten_index.push_str(&format!(
            "{new_backup_file_name}\t{}\n",
            entry.original_path
        ));
    }

    write_text_file_durably(index_path, &rewritten_index)?;
    Ok(PruneBackupsSummary {
        removed_count,
        kept_count: rewritten_index.lines().count(),
    })
}

fn plan_obsolete_deck_backups(backup_dir: &Path) -> io::Result<PruneDeckBackupsPlan> {
    let index_path = deck_backup_index_path(backup_dir);
    if !index_path
        .try_exists()
        .map_err(|error| path_io_error("could not check deck backup index", &index_path, error))?
    {
        return Ok(PruneDeckBackupsPlan {
            index_path,
            index_exists: false,
            obsolete_entries: Vec::new(),
            kept_entries: Vec::new(),
        });
    }

    let index_text = fs::read_to_string(&index_path)
        .map_err(|error| path_io_error("could not read deck backup index", &index_path, error))?;
    let entries = parse_deck_backup_index(&index_text);
    let mut newest_by_original_path: BTreeMap<String, DeckBackupIndexEntry> = BTreeMap::new();
    let mut obsolete_entries = Vec::new();

    for entry in entries {
        let backup_path = backup_dir.join(&entry.backup_file_name);
        if !backup_path
            .try_exists()
            .map_err(|error| path_io_error("could not check backup file", &backup_path, error))?
        {
            continue;
        }

        match newest_by_original_path.get(&entry.original_path) {
            Some(existing) if existing.sequence_number > entry.sequence_number => {
                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();
    for (index, entry) in kept_entries.iter().enumerate() {
        let new_sequence_number = index as u64 + 1;
        let target_path = deck_backup_path(
            Path::new(&entry.original_path),
            backup_dir,
            new_sequence_number,
        );
        let current_path = backup_dir.join(&entry.backup_file_name);
        if target_path != current_path
            && target_path.try_exists().map_err(|error| {
                path_io_error("could not check backup file", &target_path, error)
            })?
        {
            let target_file_name = target_path
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or("");
            let target_is_indexed = parse_deck_backup_index(&index_text)
                .iter()
                .any(|indexed| indexed.backup_file_name == target_file_name);
            if !target_is_indexed {
                return Err(io::Error::new(
                    io::ErrorKind::AlreadyExists,
                    format!(
                        "cannot renumber backups because {} already exists and is not listed in index.txt",
                        target_path.display()
                    ),
                ));
            }
        }
    }

    Ok(PruneDeckBackupsPlan {
        index_path,
        index_exists: true,
        obsolete_entries,
        kept_entries,
    })
}

pub(crate) fn wipe_backup_directory(backup_dir: &Path) -> io::Result<usize> {
    if !backup_dir
        .try_exists()
        .map_err(|error| path_io_error("could not check backup directory", backup_dir, error))?
    {
        return Ok(0);
    }
    if !backup_dir.is_dir() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("backup path is not a directory: {}", backup_dir.display()),
        ));
    }

    let mut removed_count = 0;
    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 path = entry.path();
        if entry
            .file_type()
            .map_err(|error| path_io_error("could not inspect backup entry", &path, error))?
            .is_dir()
        {
            fs::remove_dir_all(&path).map_err(|error| {
                path_io_error("could not remove backup directory", &path, error)
            })?;
        } else {
            fs::remove_file(&path)
                .map_err(|error| path_io_error("could not remove backup file", &path, error))?;
        }
        removed_count += 1;
    }
    best_effort_sync_parent_directory(backup_dir);
    Ok(removed_count)
}

fn parse_deck_backup_index(index_text: &str) -> Vec<DeckBackupIndexEntry> {
    index_text
        .lines()
        .filter_map(|line| {
            let (backup_file_name, original_path) = line.split_once('\t')?;
            Some(DeckBackupIndexEntry {
                backup_file_name: backup_file_name.to_string(),
                original_path: original_path.to_string(),
                sequence_number: deck_backup_number_from_name(backup_file_name)?,
            })
        })
        .collect()
}

pub(crate) fn portable_path(relative_path: impl AsRef<Path>) -> PathBuf {
    let relative_path = relative_path.as_ref();
    if relative_path.is_absolute() {
        return relative_path.to_path_buf();
    }

    portable_root_dir().join(relative_path)
}

fn portable_root_dir() -> PathBuf {
    std::env::current_exe()
        .ok()
        .and_then(|path| path.parent().map(Path::to_path_buf))
        .unwrap_or_else(|| PathBuf::from("."))
}

pub(crate) fn launch_default_browser_for_path(path: &Path) -> Result<(), ()> {
    let url = file_url_from_path(path);
    launch_default_browser_for_url(&url)
}

pub(crate) fn launch_default_browser_for_url(url: &str) -> Result<(), ()> {
    let url = CString::new(url).map_err(|_| ())?;
    // SAFETY: CString guarantees a stable NUL-terminated byte string with no
    // interior NULs. wxd_LaunchDefaultBrowser reads the pointer during the call
    // and does not take ownership of it.
    let launched = unsafe { wxdragon::ffi::wxd_LaunchDefaultBrowser(url.as_ptr(), 0) };
    launched.then_some(()).ok_or(())
}

pub(crate) fn file_url_from_path(path: &Path) -> String {
    let path_string = path.to_string_lossy().replace('\\', "/");
    let without_leading_slashes = path_string.trim_start_matches('/');
    format!(
        "file:///{}",
        percent_encode_file_url_path(without_leading_slashes)
    )
}

fn percent_encode_file_url_path(path: &str) -> String {
    let mut encoded = String::new();
    for byte in path.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
                encoded.push(byte as char);
            }
            _ => encoded.push_str(&format!("%{byte:02X}")),
        }
    }
    encoded
}

pub(crate) fn write_text_file_durably(path: impl AsRef<Path>, contents: &str) -> io::Result<()> {
    let path = path.as_ref();
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent)
            .map_err(|error| path_io_error("could not create directory", parent, error))?;
    }

    let temp_path = create_unique_temp_path(path)?;
    let write_result = (|| {
        let mut temp_file = fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&temp_path)
            .map_err(|error| path_io_error("could not create temporary file", &temp_path, error))?;
        temp_file
            .write_all(contents.as_bytes())
            .map_err(|error| path_io_error("could not write temporary file", &temp_path, error))?;
        temp_file
            .sync_all()
            .map_err(|error| path_io_error("could not flush temporary file", &temp_path, error))?;
        drop(temp_file);
        replace_file(&temp_path, path)
            .map_err(|error| path_io_error("could not replace file", path, error))
    })();

    if write_result.is_err() {
        let _ = fs::remove_file(&temp_path);
    }

    write_result
}

pub(crate) fn create_text_file_durably(path: impl AsRef<Path>, contents: &str) -> io::Result<()> {
    let path = path.as_ref();
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent)
            .map_err(|error| path_io_error("could not create directory", parent, error))?;
    }

    let mut file = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
        .map_err(|error| path_io_error("could not create file", path, error))?;
    let write_result = (|| {
        file.write_all(contents.as_bytes())
            .map_err(|error| path_io_error("could not write file", path, error))?;
        file.sync_all()
            .map_err(|error| path_io_error("could not flush file", path, error))
    })();

    if write_result.is_err() {
        let _ = fs::remove_file(path);
    } else {
        best_effort_sync_parent_directory(path);
    }

    write_result
}

pub(crate) fn create_unique_temp_path(path: &Path) -> io::Result<PathBuf> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("cosimo-file");
    let process_id = std::process::id();

    for attempt in 0..1000_u32 {
        let temp_path = parent.join(format!(".{file_name}.{process_id}.{attempt}.tmp"));
        match fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&temp_path)
        {
            Ok(file) => {
                drop(file);
                fs::remove_file(&temp_path).map_err(|error| {
                    path_io_error("could not remove temporary file probe", &temp_path, error)
                })?;
                return Ok(temp_path);
            }
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
            Err(error) => {
                return Err(path_io_error(
                    "could not create temporary file",
                    &temp_path,
                    error,
                ));
            }
        }
    }

    Err(io::Error::new(
        io::ErrorKind::AlreadyExists,
        "could not allocate a temporary file name",
    ))
}

pub(crate) fn replace_file_durably(temp_path: &Path, destination: &Path) -> io::Result<()> {
    replace_file(temp_path, destination)
}

#[cfg(unix)]
fn replace_file(temp_path: &Path, destination: &Path) -> io::Result<()> {
    fs::rename(temp_path, destination)?;
    best_effort_sync_parent_directory(destination);
    Ok(())
}

#[cfg(windows)]
fn replace_file(temp_path: &Path, destination: &Path) -> io::Result<()> {
    use std::os::windows::ffi::OsStrExt;
    use windows_sys::Win32::Storage::FileSystem::{
        MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
    };

    let temp_wide: Vec<u16> = temp_path
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();
    let destination_wide: Vec<u16> = destination
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();

    // SAFETY: temp_wide and destination_wide are valid NUL-terminated UTF-16
    // buffers that live until MoveFileExW returns. The function does not store
    // the pointers, and all ownership remains with Rust.
    let replaced = unsafe {
        MoveFileExW(
            temp_wide.as_ptr(),
            destination_wide.as_ptr(),
            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
        )
    };
    if replaced == 0 {
        Err(io::Error::last_os_error())
    } else {
        best_effort_sync_parent_directory(destination);
        Ok(())
    }
}

fn best_effort_sync_parent_directory(path: &Path) {
    let _ = sync_parent_directory(path);
}

#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> io::Result<()> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    fs::File::open(parent)?.sync_all()
}

#[cfg(windows)]
fn sync_parent_directory(_path: &Path) -> io::Result<()> {
    // Windows replacements in this module use MoveFileExW with
    // MOVEFILE_WRITE_THROUGH. There is no direct portable std equivalent for
    // opening and syncing a directory handle, so this keeps the extra
    // directory-sync step explicit without weakening the write-through move.
    Ok(())
}

#[cfg(not(any(unix, windows)))]
fn sync_parent_directory(_path: &Path) -> io::Result<()> {
    Ok(())
}

pub(crate) fn path_io_error(action: &str, path: &Path, error: io::Error) -> io::Error {
    io::Error::new(
        error.kind(),
        format!("{action} {}: {error}", path.display()),
    )
}

pub(crate) fn two_path_io_error(
    action: &str,
    source: &Path,
    destination: &Path,
    error: io::Error,
) -> io::Error {
    io::Error::new(
        error.kind(),
        format!(
            "{action} from {} to {}: {error}",
            source.display(),
            destination.display()
        ),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    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-file-ops-{prefix}-{}-{unique}",
            std::process::id()
        ));
        fs::create_dir_all(&path).unwrap();
        path
    }

    #[test]
    fn parent_directory_sync_accepts_existing_parent() {
        let temp_dir = unique_temp_dir("parent-sync");
        let path = temp_dir.join("deck.cosimo-deck");
        fs::write(&path, "Question\n---\nAnswer\n===\n").unwrap();

        sync_parent_directory(&path).unwrap();
    }

    #[test]
    fn durable_text_write_replaces_file_contents() {
        let temp_dir = unique_temp_dir("durable-write");
        let path = temp_dir.join("deck.cosimo-deck");
        write_text_file_durably(&path, "Original\n---\nAnswer\n===\n").unwrap();

        write_text_file_durably(&path, "Updated\n---\nAnswer\n===\n").unwrap();

        assert_eq!(
            fs::read_to_string(path).unwrap(),
            "Updated\n---\nAnswer\n===\n"
        );
    }

    #[test]
    fn durable_text_write_reports_parent_directory_creation_path() {
        let temp_dir = unique_temp_dir("durable-write-parent-error");
        let not_a_directory = temp_dir.join("not-a-directory");
        fs::write(&not_a_directory, "file\n").unwrap();
        let path = not_a_directory.join("deck.cosimo-deck");

        let error = write_text_file_durably(&path, "Question\n---\nAnswer\n===\n").unwrap_err();

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

    #[test]
    fn deck_backup_policy_codes_round_trip_for_all_options() {
        for policy in DeckBackupPolicy::ALL.iter().copied() {
            assert_eq!(DeckBackupPolicy::from_code(policy.code()), Some(policy));
        }
    }

    #[test]
    fn wipe_backup_directory_removes_all_entries_but_keeps_directory() {
        let temp_dir = unique_temp_dir("wipe-backup-directory");
        let backup_dir = temp_dir.join("backups");
        fs::create_dir_all(backup_dir.join("nested")).unwrap();
        fs::write(backup_dir.join("index.txt"), "indexed\n").unwrap();
        fs::write(
            backup_dir.join("manual-note.txt"),
            "not generated by Cosimo\n",
        )
        .unwrap();
        fs::write(backup_dir.join("nested").join("deck.cosimo-deck"), "deck\n").unwrap();

        let removed_count = wipe_backup_directory(&backup_dir).unwrap();

        assert_eq!(removed_count, 3);
        assert!(backup_dir.is_dir());
        assert_eq!(fs::read_dir(&backup_dir).unwrap().count(), 0);
    }

    #[test]
    fn deck_backup_index_write_failure_reports_index_path() {
        let temp_dir = unique_temp_dir("deck-backup-index-error");
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let backup_dir = temp_dir.join("backups");
        let index_path = backup_dir.join("index.txt");
        fs::create_dir_all(&index_path).unwrap();
        fs::write(&deck_path, "Question\n---\nAnswer\n=1=\n").unwrap();

        let error = write_deck_file_durably(
            &deck_path,
            &backup_dir,
            DeckBackupPolicy::Full,
            "Updated\n---\nAnswer\n=1=\n",
        )
        .unwrap_err();

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

    #[test]
    fn prune_backup_index_read_failure_reports_index_path() {
        let temp_dir = unique_temp_dir("prune-index-error");
        let backup_dir = temp_dir.join("backups");
        let index_path = backup_dir.join("index.txt");
        fs::create_dir_all(&index_path).unwrap();

        let error = prune_obsolete_deck_backups(&backup_dir).unwrap_err();

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