use std::path::Path;

use lanspread_db::db::GameFileDescription;
use tokio::{io::AsyncWriteExt, sync::Mutex};

#[derive(Debug)]
pub(super) struct VersionIniBuffer {
    relative_path: String,
    bytes: Mutex<Vec<u8>>,
}

impl VersionIniBuffer {
    pub(super) fn new(desc: &GameFileDescription) -> eyre::Result<Self> {
        if desc.is_dir {
            eyre::bail!("version.ini sentinel cannot be a directory");
        }
        let size = usize::try_from(desc.size)?;
        Ok(Self {
            relative_path: desc.relative_path.clone(),
            bytes: Mutex::new(vec![0; size]),
        })
    }

    pub(super) fn matches(&self, relative_path: &str) -> bool {
        self.relative_path == relative_path
    }

    pub(super) async fn write_at(&self, offset: u64, bytes: &[u8]) -> eyre::Result<()> {
        let offset = usize::try_from(offset)?;
        let mut buffer = self.bytes.lock().await;
        let end = offset
            .checked_add(bytes.len())
            .ok_or_else(|| eyre::eyre!("version.ini chunk offset overflow"))?;
        if end > buffer.len() {
            eyre::bail!(
                "version.ini chunk exceeds buffer: end {end}, buffer {}",
                buffer.len()
            );
        }
        buffer[offset..end].copy_from_slice(bytes);
        Ok(())
    }

    async fn snapshot(&self) -> Vec<u8> {
        self.bytes.lock().await.clone()
    }
}

pub(super) async fn begin_version_ini_transaction(game_root: &Path) -> eyre::Result<()> {
    tokio::fs::create_dir_all(game_root).await?;
    remove_file_if_exists(&game_root.join(".version.ini.tmp")).await?;
    remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?;

    let version_path = game_root.join("version.ini");
    if tokio::fs::metadata(&version_path)
        .await
        .is_ok_and(|metadata| metadata.is_file())
    {
        tokio::fs::rename(version_path, game_root.join(".version.ini.discarded")).await?;
    }
    Ok(())
}

pub(super) async fn rollback_version_ini_transaction(game_root: &Path) {
    if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.tmp")).await {
        log::warn!(
            "Failed to sweep partial version.ini tmp in {}: {err}",
            game_root.display()
        );
    }
    if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.discarded")).await {
        log::warn!(
            "Failed to sweep discarded version.ini in {}: {err}",
            game_root.display()
        );
    }
}

pub(super) async fn commit_version_ini_buffer(
    game_root: &Path,
    buffer: &VersionIniBuffer,
) -> eyre::Result<()> {
    let tmp_path = game_root.join(".version.ini.tmp");
    let version_path = game_root.join("version.ini");
    let bytes = buffer.snapshot().await;

    let mut file = tokio::fs::File::create(&tmp_path).await?;
    file.write_all(&bytes).await?;
    file.sync_all().await?;
    drop(file);

    tokio::fs::rename(&tmp_path, &version_path).await?;
    sync_parent_dir(&version_path)?;
    remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?;
    Ok(())
}

#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::File::open(parent)?.sync_all()?;
    }
    Ok(())
}

#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
    Ok(())
}

async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
    match tokio::fs::remove_file(path).await {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err.into()),
    }
}

#[cfg(test)]
mod tests {
    use lanspread_db::db::GameFileDescription;

    use super::*;
    use crate::test_support::TempDir;

    #[tokio::test]
    async fn version_ini_buffer_accepts_out_of_order_chunks() {
        let desc = GameFileDescription {
            game_id: "game".to_string(),
            relative_path: "game/version.ini".to_string(),
            is_dir: false,
            size: 8,
        };
        let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created");

        buffer
            .write_at(4, b"0101")
            .await
            .expect("second chunk should write");
        buffer
            .write_at(0, b"2025")
            .await
            .expect("first chunk should write");

        assert_eq!(buffer.snapshot().await, b"20250101");
    }

    #[tokio::test]
    async fn commit_version_ini_writes_sentinel_last_and_sweeps_discarded() {
        let temp = TempDir::new("lanspread-download");
        let game_root = temp.path().join("game");
        tokio::fs::create_dir_all(&game_root)
            .await
            .expect("game root should be created");
        tokio::fs::write(game_root.join(".version.ini.discarded"), b"old")
            .await
            .expect("discarded sentinel should be written");

        let desc = GameFileDescription {
            game_id: "game".to_string(),
            relative_path: "game/version.ini".to_string(),
            is_dir: false,
            size: 8,
        };
        let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created");
        buffer
            .write_at(0, b"20250101")
            .await
            .expect("version should be buffered");

        commit_version_ini_buffer(&game_root, &buffer)
            .await
            .expect("version sentinel should commit");

        assert_eq!(
            std::fs::read(game_root.join("version.ini")).expect("version.ini should exist"),
            b"20250101"
        );
        assert!(!game_root.join(".version.ini.tmp").exists());
        assert!(!game_root.join(".version.ini.discarded").exists());
    }

    #[tokio::test]
    async fn begin_version_ini_transaction_parks_existing_sentinel() {
        let temp = TempDir::new("lanspread-download");
        let game_root = temp.path().join("game");
        tokio::fs::create_dir_all(&game_root)
            .await
            .expect("game root should be created");
        tokio::fs::write(game_root.join("version.ini"), b"20240101")
            .await
            .expect("version sentinel should be written");
        tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
            .await
            .expect("tmp sentinel should be written");

        begin_version_ini_transaction(&game_root)
            .await
            .expect("transaction should begin");

        assert!(!game_root.join("version.ini").exists());
        assert!(!game_root.join(".version.ini.tmp").exists());
        assert_eq!(
            std::fs::read(game_root.join(".version.ini.discarded"))
                .expect("discarded sentinel should exist"),
            b"20240101"
        );
    }

    #[tokio::test]
    async fn rollback_version_ini_transaction_sweeps_transients() {
        let temp = TempDir::new("lanspread-download");
        let game_root = temp.path().join("game");
        tokio::fs::create_dir_all(&game_root)
            .await
            .expect("game root should be created");
        tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
            .await
            .expect("tmp sentinel should be written");
        tokio::fs::write(game_root.join(".version.ini.discarded"), b"old")
            .await
            .expect("discarded sentinel should be written");

        rollback_version_ini_transaction(&game_root).await;

        assert!(!game_root.join(".version.ini.tmp").exists());
        assert!(!game_root.join(".version.ini.discarded").exists());
    }
}
