//! Transactional scene persistence and user-local recovery snapshot storage.

use std::fs::{self, File, OpenOptions};
use std::hash::Hasher;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

const STATE_DIR: &str = "blacksite-editor";
const RECOVERY_DIR: &str = "recovery";

pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
    atomic_write_with_pre_rename(path, bytes, || Ok(()))
}

pub(crate) fn atomic_write_with_pre_rename<E>(
    path: &Path,
    bytes: &[u8],
    before_rename: impl FnOnce() -> Result<(), E>,
) -> Result<(), E>
where
    E: From<String>,
{
    let parent = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent)
        .map_err(|error| E::from(format!("could not create {}: {error}", parent.display())))?;

    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("scene");
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    let temporary = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id()));

    let result = (|| {
        let mut file = OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(&temporary)
            .map_err(|error| {
                E::from(format!("could not create {}: {error}", temporary.display()))
            })?;
        file.write_all(bytes).map_err(|error| {
            E::from(format!("could not write {}: {error}", temporary.display()))
        })?;
        file.sync_all()
            .map_err(|error| E::from(format!("could not sync {}: {error}", temporary.display())))?;
        before_rename()?;
        fs::rename(&temporary, path).map_err(|error| {
            E::from(format!(
                "could not replace {} with {}: {error}",
                path.display(),
                temporary.display()
            ))
        })?;
        sync_parent_directory(parent);
        Ok(())
    })();

    if result.is_err() {
        let _ = fs::remove_file(&temporary);
    }
    result
}

#[cfg(unix)]
fn sync_parent_directory(parent: &Path) {
    if let Ok(directory) = File::open(parent) {
        let _ = directory.sync_all();
    }
}

#[cfg(not(unix))]
fn sync_parent_directory(_parent: &Path) {}

pub(crate) fn default_state_root() -> Option<PathBuf> {
    if let Some(path) = std::env::var_os("XDG_STATE_HOME") {
        return Some(PathBuf::from(path).join(STATE_DIR));
    }
    std::env::var_os("HOME")
        .map(PathBuf::from)
        .map(|home| home.join(".local/state").join(STATE_DIR))
}

pub(crate) fn write_recovery_snapshot(
    state_root: &Path,
    project_root: &Path,
    scene_path: &Path,
    bytes: &[u8],
    max_generations: usize,
) -> Result<PathBuf, String> {
    let directory = recovery_directory(state_root, project_root, scene_path);
    fs::create_dir_all(&directory)
        .map_err(|error| format!("could not create {}: {error}", directory.display()))?;
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis();
    let snapshot = directory.join(format!(
        "recovery-{timestamp:020}-{}.scn.ron",
        std::process::id()
    ));
    atomic_write(&snapshot, bytes)?;
    prune_recovery_generations(&directory, max_generations.max(1))?;
    Ok(snapshot)
}

pub(crate) fn latest_recovery_snapshot(
    state_root: &Path,
    project_root: &Path,
    scene_path: &Path,
) -> Option<PathBuf> {
    let directory = recovery_directory(state_root, project_root, scene_path);
    let source_modified = fs::metadata(scene_path)
        .and_then(|metadata| metadata.modified())
        .unwrap_or(UNIX_EPOCH);
    recovery_files(&directory)
        .into_iter()
        .filter(|path| {
            fs::metadata(path)
                .and_then(|metadata| metadata.modified())
                .is_ok_and(|modified| modified > source_modified)
        })
        .max_by_key(|path| path.file_name().map(|name| name.to_os_string()))
}

pub(crate) fn discard_recovery_snapshots(
    state_root: &Path,
    project_root: &Path,
    scene_path: &Path,
) -> Result<(), String> {
    let directory = recovery_directory(state_root, project_root, scene_path);
    if !directory.exists() {
        return Ok(());
    }
    fs::remove_dir_all(&directory)
        .map_err(|error| format!("could not remove {}: {error}", directory.display()))
}

fn recovery_directory(state_root: &Path, project_root: &Path, scene_path: &Path) -> PathBuf {
    state_root
        .join(RECOVERY_DIR)
        .join(stable_path_hash(project_root))
        .join(stable_path_hash(scene_path))
}

fn stable_path_hash(path: &Path) -> String {
    let normalized = path.to_string_lossy().replace('\\', "/");
    let mut hasher = Fnv1a64::default();
    hasher.write(normalized.as_bytes());
    format!("{:016x}", hasher.finish())
}

fn prune_recovery_generations(directory: &Path, max_generations: usize) -> Result<(), String> {
    let mut files = recovery_files(directory);
    files.sort_by_key(|path| path.file_name().map(|name| name.to_os_string()));
    let remove_count = files.len().saturating_sub(max_generations);
    for path in files.into_iter().take(remove_count) {
        fs::remove_file(&path)
            .map_err(|error| format!("could not prune {}: {error}", path.display()))?;
    }
    Ok(())
}

fn recovery_files(directory: &Path) -> Vec<PathBuf> {
    let Ok(entries) = fs::read_dir(directory) else {
        return Vec::new();
    };
    entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| {
            path.file_name()
                .and_then(|name| name.to_str())
                .is_some_and(|name| name.starts_with("recovery-") && name.ends_with(".scn.ron"))
        })
        .collect()
}

#[derive(Default)]
struct Fnv1a64(u64);

impl Hasher for Fnv1a64 {
    fn finish(&self) -> u64 {
        if self.0 == 0 {
            0xcbf29ce484222325
        } else {
            self.0
        }
    }

    fn write(&mut self, bytes: &[u8]) {
        let mut hash = self.finish();
        for byte in bytes {
            hash ^= u64::from(*byte);
            hash = hash.wrapping_mul(0x100000001b3);
        }
        self.0 = hash;
    }
}

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

    fn temp_directory(label: &str) -> PathBuf {
        std::env::temp_dir().join(format!("blacksite-{label}-{}", uuid::Uuid::new_v4()))
    }

    #[test]
    fn atomic_write_replaces_content_without_leaving_temporary_files() {
        let root = temp_directory("atomic-write");
        let path = root.join("scene.scn.ron");
        fs::create_dir_all(&root).unwrap();
        fs::write(&path, b"old").unwrap();

        atomic_write(&path, b"new scene").unwrap();

        assert_eq!(fs::read(&path).unwrap(), b"new scene");
        assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn interrupted_atomic_write_preserves_the_last_authored_scene() {
        let root = temp_directory("atomic-interruption");
        let path = root.join("scene.scn.ron");
        fs::create_dir_all(&root).unwrap();
        fs::write(&path, b"last known good scene").unwrap();

        let result = atomic_write_with_pre_rename(&path, b"partial replacement", || {
            Err("simulated interruption before rename".to_string())
        });

        assert_eq!(result.unwrap_err(), "simulated interruption before rename");
        assert_eq!(fs::read(&path).unwrap(), b"last known good scene");
        assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn recovery_paths_are_stable_and_scene_specific() {
        let state = Path::new("/state");
        let project = Path::new("/project");
        let first = recovery_directory(state, project, Path::new("assets/levels/a.scn.ron"));
        let repeated = recovery_directory(state, project, Path::new("assets/levels/a.scn.ron"));
        let second = recovery_directory(state, project, Path::new("assets/levels/b.scn.ron"));

        assert_eq!(first, repeated);
        assert_ne!(first, second);
        assert!(first.starts_with("/state/recovery"));
    }

    #[test]
    fn newer_recovery_generation_is_discovered_for_authored_scene() {
        let root = temp_directory("recovery-discovery");
        let project = Path::new("/project");
        let scene = root.join("assets/levels/test.scn.ron");
        fs::create_dir_all(scene.parent().unwrap()).unwrap();
        fs::write(&scene, b"authored").unwrap();
        std::thread::sleep(std::time::Duration::from_millis(10));

        let snapshot = write_recovery_snapshot(&root, project, &scene, b"recovered", 5).unwrap();

        assert_eq!(
            latest_recovery_snapshot(&root, project, &scene),
            Some(snapshot)
        );
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn recovery_generations_are_bounded() {
        let root = temp_directory("recovery-prune");
        let project = Path::new("/project");
        let scene = Path::new("assets/levels/test.scn.ron");
        let directory = recovery_directory(&root, project, scene);
        fs::create_dir_all(&directory).unwrap();
        for index in 0..5 {
            fs::write(
                directory.join(format!("recovery-{index:020}-1.scn.ron")),
                index.to_string(),
            )
            .unwrap();
        }

        prune_recovery_generations(&directory, 3).unwrap();

        let mut names: Vec<String> = recovery_files(&directory)
            .into_iter()
            .map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
            .collect();
        names.sort();
        assert_eq!(
            names,
            vec![
                "recovery-00000000000000000002-1.scn.ron",
                "recovery-00000000000000000003-1.scn.ron",
                "recovery-00000000000000000004-1.scn.ron",
            ]
        );
        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn discard_removes_all_scene_generations() {
        let root = temp_directory("recovery-discard");
        let project = Path::new("/project");
        let scene = Path::new("assets/levels/test.scn.ron");
        let directory = recovery_directory(&root, project, scene);
        fs::create_dir_all(&directory).unwrap();
        fs::write(
            directory.join("recovery-00000000000000000001-1.scn.ron"),
            b"one",
        )
        .unwrap();
        fs::write(
            directory.join("recovery-00000000000000000002-1.scn.ron"),
            b"two",
        )
        .unwrap();

        discard_recovery_snapshots(&root, project, scene).unwrap();

        assert!(!directory.exists());
        fs::remove_dir_all(root).unwrap();
    }
}
