//! Filesystem boundary for the generic runner.

#![expect(
    clippy::disallowed_methods,
    reason = "The runner is the I/O layer; raw writes stay centralized here."
)]
#![expect(
    clippy::type_complexity,
    reason = "`std::io::Result<Option<Vec<u8>>>` exposes the absence branch directly."
)]

use std::path::{Path, PathBuf};

use aqc_filetree::{FileKind, WalkOptions, build_file_tree};
use aqc_fs_utils::{ReadBytesOptions, ReadError, read_bytes};

/// Read a file's bytes, or `None` if it does not exist.
///
/// # Errors
///
/// Returns an I/O-shaped error for every read failure other than absence.
pub(crate) fn read(path: &Path) -> std::io::Result<Option<Vec<u8>>> {
    match read_bytes(path, &ReadBytesOptions::default()) {
        Ok(bytes) => Ok(Some(bytes)),
        Err(ReadError::NotFound) => Ok(None),
        Err(err) => Err(std::io::Error::other(err)),
    }
}

/// Find every Rustfmt config file under `root`.
///
/// # Errors
///
/// Returns the file-tree walk error as an I/O-shaped error for the caller's
/// existing abort path.
pub(crate) fn rustfmt_config_paths(root: &Path) -> std::io::Result<Vec<PathBuf>> {
    let tree = build_file_tree(root, &WalkOptions::default()).map_err(std::io::Error::other)?;
    let mut paths: Vec<PathBuf> = tree
        .entries()
        .iter()
        .filter(|entry| entry.kind == FileKind::File)
        .filter(|entry| {
            entry
                .rel_path
                .rsplit('/')
                .next()
                .is_some_and(|name| name == "rustfmt.toml" || name == ".rustfmt.toml")
        })
        .map(|entry| root.join(&entry.rel_path))
        .collect();
    paths.sort();
    Ok(paths)
}

/// Atomically write `bytes` to `path`.
///
/// # Errors
///
/// Returns the underlying I/O error if staging or rename fails.
pub(crate) fn write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    let temp = temp_sibling(path);
    std::fs::write(&temp, bytes)?;
    match std::fs::rename(&temp, path) {
        Ok(()) => Ok(()),
        Err(err) => {
            let _ = std::fs::remove_file(&temp);
            Err(err)
        }
    }
}

/// Write many files all-or-nothing.
///
/// # Errors
///
/// Returns the first I/O error from staging or renaming.
pub(crate) fn write_all(files: &[(PathBuf, Vec<u8>)]) -> std::io::Result<()> {
    let mut staged: Vec<(PathBuf, PathBuf)> = Vec::new();
    for (path, bytes) in files {
        let temp = temp_sibling(path);
        if let Err(err) = std::fs::write(&temp, bytes) {
            for (staged_temp, _) in &staged {
                let _ = std::fs::remove_file(staged_temp);
            }
            return Err(err);
        }
        staged.push((temp, path.clone()));
    }
    for (temp, path) in &staged {
        std::fs::rename(temp, path)?;
    }
    Ok(())
}

fn temp_sibling(path: &Path) -> PathBuf {
    let mut name = path.file_name().map_or_else(
        || ".shakrs".to_owned(),
        |name| name.to_string_lossy().into_owned(),
    );
    name.push_str(".shakrs-tmp");
    path.with_file_name(name)
}
