//! Atomic JSON persistence for command set data.
//!
//! Writes to a `.tmp` file first, then `fsync`s and `rename`s to the target
//! path for crash safety. Falls back to `copy` + `remove` on cross-filesystem
//! moves (EXDEV). Corrupted files are backed up to `sets.json.bak`.

use crate::config::{data_file_path, temp_file_path};
use crate::error::StorageError;
use crate::models::AppData;
use std::fs;
use std::io::{self, Write};
use std::path::Path;

/// Load application data from the default config file.
pub fn load_app_data() -> Result<AppData, StorageError> {
    let path = data_file_path();
    load_app_data_from(&path)
}

/// Save application data atomically to the default config file.
pub fn save_app_data(data: &AppData) -> Result<(), StorageError> {
    let path = data_file_path();
    let tmp = temp_file_path();
    save_app_data_to(data, &path, &tmp)
}

// ---- Internal path-accepting functions (used by tests too) ----

pub(crate) fn load_app_data_from(path: &Path) -> Result<AppData, StorageError> {
    if !path.exists() {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .map_err(|e| StorageError::CreateDir(format!("{}: {}", parent.display(), e)))?;
        }
        return Ok(AppData::empty());
    }

    let content = fs::read_to_string(path)
        .map_err(|e| StorageError::ReadFailed(format!("{}: {}", path.display(), e)))?;

    match serde_json::from_str(&content) {
        Ok(data) => Ok(data),
        Err(e) => {
            let bak = path.with_extension("json.bak");
            let path_str = path.display().to_string();
            let bak_str = bak.display().to_string();
            eprintln!("Corrupted data, backing up to .bak");
            let _ = fs::rename(path, &bak);
            if let Some(parent) = path.parent()
                && let Ok(dir) = fs::File::open(parent)
            {
                let _ = dir.sync_all();
            }
            Err(StorageError::Corrupted {
                path: path_str,
                backup: bak_str,
                detail: e.to_string(),
            })
        }
    }
}

pub(crate) fn save_app_data_to(
    data: &AppData,
    path: &Path,
    tmp: &Path,
) -> Result<(), StorageError> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }

    let json =
        serde_json::to_string_pretty(data).map_err(|e| StorageError::Serde(e.to_string()))?;

    // Write to temp file and fsync (ensure data reaches disk before rename)
    let mut file = fs::File::create(tmp)?;
    file.write_all(json.as_bytes())?;
    file.sync_all()?;
    drop(file);

    // Atomic rename. Fall back to copy+remove on cross-filesystem.
    if let Err(e) = fs::rename(tmp, path) {
        if e.kind() == io::ErrorKind::CrossesDevices {
            fs::copy(tmp, path)?;
            let _ = fs::remove_file(tmp);
            // Sync parent directory after copy+remove too
            if let Some(parent) = path.parent()
                && let Ok(dir) = fs::File::open(parent)
            {
                let _ = dir.sync_all();
            }
        } else {
            return Err(StorageError::Io(e));
        }
    } else {
        // Sync parent directory metadata so the rename is durable
        if let Some(parent) = path.parent()
            && let Ok(dir) = fs::File::open(parent)
        {
            let _ = dir.sync_all();
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{CommandSet, Group};
    use std::fs;
    use uuid::Uuid;

    /// Helper: create a temporary directory and run the test closure.
    /// Returns the directory path so tests can use it.
    fn with_temp_dir(f: impl FnOnce(&Path)) {
        let tmp_dir = std::env::temp_dir().join(format!("shellpad_test_{}", Uuid::new_v4()));
        let _ = fs::create_dir_all(&tmp_dir);
        f(&tmp_dir);
        let _ = fs::remove_dir_all(&tmp_dir);
    }

    #[test]
    fn test_load_empty_on_first_run() {
        with_temp_dir(|dir| {
            let path = dir.join("sets.json");
            let data = load_app_data_from(&path).unwrap();
            assert!(data.groups.is_empty());
        });
    }

    #[test]
    fn test_save_and_load_roundtrip() {
        with_temp_dir(|dir| {
            let path = dir.join("sets.json");
            let tmp = dir.join("sets.json.tmp");

            let mut group = Group::new("Test".to_string());
            let set = CommandSet::new("Test Set".to_string(), group.id);
            group.sets.push(set);
            let data = AppData {
                groups: vec![group],
            };

            save_app_data_to(&data, &path, &tmp).expect("save failed");
            let loaded = load_app_data_from(&path).unwrap();
            assert_eq!(data, loaded);
        });
    }

    #[test]
    fn test_atomic_write_does_not_leave_tmp() {
        with_temp_dir(|dir| {
            let path = dir.join("sets.json");
            let tmp = dir.join("sets.json.tmp");

            let data = AppData::empty();
            save_app_data_to(&data, &path, &tmp).expect("save failed");

            let content = fs::read_to_string(&path).expect("read");
            assert!(content.contains("\"groups\""));
            assert!(!tmp.exists());
        });
    }

    #[test]
    fn test_corrupted_file_backs_up() {
        with_temp_dir(|dir| {
            let path = dir.join("sets.json");
            fs::write(&path, "this is not json").unwrap();

            // Corrupted file should return Err and create backup
            assert!(load_app_data_from(&path).is_err());

            let bak = path.with_extension("json.bak");
            assert!(bak.exists());
        });
    }

    #[test]
    fn test_multiple_saves() {
        with_temp_dir(|dir| {
            let path = dir.join("sets.json");
            let tmp = dir.join("sets.json.tmp");

            let data1 = AppData::empty();
            save_app_data_to(&data1, &path, &tmp).unwrap();

            let mut group = Group::new("G".to_string());
            group.sets.push(CommandSet::new("S".to_string(), group.id));
            let data2 = AppData {
                groups: vec![group],
            };
            save_app_data_to(&data2, &path, &tmp).unwrap();

            let loaded = load_app_data_from(&path).unwrap();
            assert_eq!(loaded.groups.len(), 1);
            assert_eq!(loaded.groups[0].name, "G");
        });
    }
}
