use std::{
    collections::HashSet,
    io::ErrorKind,
    path::{Path, PathBuf},
    sync::Arc,
};

use eyre::WrapErr;

use super::{
    intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
    unpack::Unpacker,
};
use crate::local_games::version_ini_is_regular_file;

const LOCAL_DIR: &str = "local";
const INSTALLING_DIR: &str = ".local.installing";
const BACKUP_DIR: &str = ".local.backup";
const OWNED_MARKER: &str = ".lanspread_owned";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FsEntryState {
    Present,
    Missing,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct InstallFsState {
    local: FsEntryState,
    installing: FsEntryState,
    backup: FsEntryState,
}

pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
    let eti_version = read_downloaded_version(game_root).await;
    write_intent(
        game_root,
        &InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
    )
    .await?;

    let result = install_inner(game_root, id, unpacker).await;
    match result {
        Ok(()) => {
            write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
            Ok(())
        }
        Err(err) => {
            if let Err(cleanup_err) = remove_dir_all_if_exists(&installing_dir(game_root)).await {
                log::warn!(
                    "Failed to clean install staging {}: {cleanup_err}",
                    installing_dir(game_root).display()
                );
            }
            write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
            Err(err)
        }
    }
}

pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
    let eti_version = read_downloaded_version(game_root).await;
    write_intent(
        game_root,
        &InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
    )
    .await?;

    let result = update_inner(game_root, id, unpacker).await;
    match result {
        Ok(()) => {
            write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
            if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
                log::warn!(
                    "Failed to clean install backup {}: {err}",
                    backup_dir(game_root).display()
                );
            }
            Ok(())
        }
        Err(err) => {
            let rollback = rollback_update(game_root).await;
            write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
            if let Err(rollback_err) = rollback {
                return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
            }
            Err(err)
        }
    }
}

pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
    let eti_version = read_downloaded_version(game_root).await;
    write_intent(
        game_root,
        &InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
    )
    .await?;

    let result = uninstall_inner(game_root).await;
    match result {
        Ok(()) => {
            write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
            Ok(())
        }
        Err(err) => {
            let rollback = restore_backup(game_root).await;
            if let Err(rollback_err) = rollback {
                return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
            }
            write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
            Err(err)
        }
    }
}

pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -> eyre::Result<()> {
    recover_download_transients(game_dir).await?;

    let mut entries = match tokio::fs::read_dir(game_dir).await {
        Ok(entries) => entries,
        Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
        Err(err) => return Err(err.into()),
    };

    while let Some(entry) = entries.next_entry().await? {
        if !entry.file_type().await?.is_dir() {
            continue;
        }

        let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
            continue;
        };
        if id == ".lanspread" {
            continue;
        }
        if active_ids.contains(&id) {
            log::debug!("Skipping recovery for active game root {id}");
            continue;
        }

        recover_game_root(&entry.path(), &id).await?;
    }

    Ok(())
}

pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
    recover_download_transients(game_root).await?;

    let intent = read_intent(game_root, id).await;
    let fs = inspect_install_fs(game_root).await;
    match intent.state {
        InstallIntentState::None => recover_none_intent(game_root).await?,
        InstallIntentState::Installing => recover_installing(game_root, id, intent, fs).await?,
        InstallIntentState::Updating => recover_updating(game_root, id, intent, fs).await?,
        InstallIntentState::Uninstalling => recover_uninstalling(game_root, id, intent, fs).await?,
    }
    Ok(())
}

async fn install_inner(
    game_root: &Path,
    id: &str,
    unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
    let local = local_dir(game_root);
    if path_is_dir(&local).await {
        eyre::bail!("game {id} is already installed");
    }

    let staging = installing_dir(game_root);
    prepare_owned_empty_dir(&staging).await?;
    unpack_archives(game_root, &staging, unpacker).await?;
    tokio::fs::rename(&staging, &local)
        .await
        .wrap_err_with(|| format!("failed to promote install for {id}"))?;
    Ok(())
}

async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
    let local = local_dir(game_root);
    let backup = backup_dir(game_root);
    let staging = installing_dir(game_root);

    if !path_is_dir(&local).await {
        eyre::bail!("game {id} is not installed");
    }
    prepare_backup_slot(&backup).await?;
    tokio::fs::rename(&local, &backup)
        .await
        .wrap_err_with(|| format!("failed to move existing install for {id} to backup"))?;
    drop_owned_marker(&backup).await?;

    prepare_owned_empty_dir(&staging).await?;
    unpack_archives(game_root, &staging, unpacker).await?;
    tokio::fs::rename(&staging, &local)
        .await
        .wrap_err_with(|| format!("failed to promote update for {id}"))?;
    Ok(())
}

async fn uninstall_inner(game_root: &Path) -> eyre::Result<()> {
    let local = local_dir(game_root);
    let backup = backup_dir(game_root);

    if !path_is_dir(&local).await {
        return Ok(());
    }

    prepare_backup_slot(&backup).await?;
    tokio::fs::rename(&local, &backup).await?;
    drop_owned_marker(&backup).await?;
    tokio::fs::remove_dir_all(&backup).await?;
    Ok(())
}

async fn unpack_archives(
    game_root: &Path,
    staging: &Path,
    unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
    let archives = root_eti_archives(game_root).await?;
    if archives.is_empty() {
        eyre::bail!("no .eti archives found in {}", game_root.display());
    }

    for archive in archives {
        unpacker.unpack(&archive, staging).await?;
    }
    Ok(())
}

async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
    let mut entries = tokio::fs::read_dir(game_root).await?;
    let mut archives = Vec::new();
    while let Some(entry) = entries.next_entry().await? {
        if !entry.file_type().await?.is_file() {
            continue;
        }
        let path = entry.path();
        if path.extension().is_some_and(|extension| extension == "eti") {
            archives.push(path);
        }
    }
    archives.sort();
    Ok(archives)
}

async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
    sweep_owned_orphan(&installing_dir(game_root)).await?;
    sweep_owned_orphan(&backup_dir(game_root)).await?;
    Ok(())
}

async fn recover_installing(
    game_root: &Path,
    id: &str,
    intent: InstallIntent,
    fs: InstallFsState,
) -> eyre::Result<()> {
    if let InstallFsState {
        installing: FsEntryState::Present,
        ..
    } = fs
    {
        remove_dir_all_if_exists(&installing_dir(game_root)).await?;
    }
    write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}

async fn recover_updating(
    game_root: &Path,
    id: &str,
    intent: InstallIntent,
    fs: InstallFsState,
) -> eyre::Result<()> {
    match fs {
        InstallFsState {
            local: FsEntryState::Missing,
            installing: FsEntryState::Present,
            backup: FsEntryState::Present,
        } => {
            remove_dir_all_if_exists(&installing_dir(game_root)).await?;
            restore_backup(game_root).await?;
        }
        InstallFsState {
            local: FsEntryState::Present,
            installing: FsEntryState::Present,
            backup: FsEntryState::Present,
        } => {
            remove_dir_all_if_exists(&installing_dir(game_root)).await?;
            remove_dir_all_if_exists(&backup_dir(game_root)).await?;
        }
        InstallFsState {
            local: FsEntryState::Present,
            installing: FsEntryState::Missing,
            backup: FsEntryState::Present,
        } => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
        _ => {}
    }
    write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}

async fn recover_uninstalling(
    game_root: &Path,
    id: &str,
    intent: InstallIntent,
    fs: InstallFsState,
) -> eyre::Result<()> {
    match fs {
        InstallFsState {
            local: FsEntryState::Missing,
            installing: FsEntryState::Missing,
            backup: FsEntryState::Present,
        } => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
        InstallFsState {
            local: FsEntryState::Present,
            installing: FsEntryState::Missing,
            backup: FsEntryState::Missing,
        } => uninstall_inner(game_root).await?,
        _ => {}
    }
    write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
}

async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
    remove_file_if_exists(&root.join(VERSION_TMP_FILE)).await?;
    remove_file_if_exists(&root.join(VERSION_DISCARDED_FILE)).await?;
    Ok(())
}

async fn inspect_install_fs(game_root: &Path) -> InstallFsState {
    InstallFsState {
        local: path_is_dir(&local_dir(game_root)).await.into(),
        installing: path_is_dir(&installing_dir(game_root)).await.into(),
        backup: path_is_dir(&backup_dir(game_root)).await.into(),
    }
}

async fn read_downloaded_version(game_root: &Path) -> Option<String> {
    if !version_ini_is_regular_file(game_root).await {
        return None;
    }
    match lanspread_db::db::read_version_from_ini(game_root) {
        Ok(version) => version,
        Err(err) => {
            log::warn!(
                "Failed to read version.ini in {}: {err}",
                game_root.display()
            );
            None
        }
    }
}

async fn prepare_owned_empty_dir(path: &Path) -> eyre::Result<()> {
    if path.exists() {
        if owned_marker(path).is_file() {
            tokio::fs::remove_dir_all(path).await?;
        } else {
            eyre::bail!("refusing to reuse markerless directory {}", path.display());
        }
    }
    tokio::fs::create_dir_all(path).await?;
    drop_owned_marker(path).await
}

async fn prepare_backup_slot(path: &Path) -> eyre::Result<()> {
    if !path.exists() {
        return Ok(());
    }
    if owned_marker(path).is_file() {
        tokio::fs::remove_dir_all(path).await?;
        return Ok(());
    }
    eyre::bail!("refusing to replace markerless backup {}", path.display());
}

async fn drop_owned_marker(path: &Path) -> eyre::Result<()> {
    tokio::fs::write(owned_marker(path), []).await?;
    Ok(())
}

async fn sweep_owned_orphan(path: &Path) -> eyre::Result<()> {
    if !path.exists() {
        return Ok(());
    }
    if owned_marker(path).is_file() {
        remove_dir_all_if_exists(path).await?;
    } else {
        log::warn!(
            "Leaving markerless reserved directory untouched: {}",
            path.display()
        );
    }
    Ok(())
}

async fn rollback_update(game_root: &Path) -> eyre::Result<()> {
    remove_dir_all_if_exists(&installing_dir(game_root)).await?;
    restore_backup(game_root).await
}

async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
    let local = local_dir(game_root);
    let backup = backup_dir(game_root);
    if !path_is_dir(&backup).await {
        return Ok(());
    }
    remove_dir_all_if_exists(&local).await?;
    tokio::fs::rename(&backup, &local).await?;
    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() == ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err.into()),
    }
}

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

async fn path_is_dir(path: &Path) -> bool {
    tokio::fs::metadata(path)
        .await
        .is_ok_and(|metadata| metadata.is_dir())
}

fn local_dir(game_root: &Path) -> PathBuf {
    game_root.join(LOCAL_DIR)
}

fn installing_dir(game_root: &Path) -> PathBuf {
    game_root.join(INSTALLING_DIR)
}

fn backup_dir(game_root: &Path) -> PathBuf {
    game_root.join(BACKUP_DIR)
}

fn owned_marker(path: &Path) -> PathBuf {
    path.join(OWNED_MARKER)
}

impl From<bool> for FsEntryState {
    fn from(value: bool) -> Self {
        if value { Self::Present } else { Self::Missing }
    }
}

#[cfg(test)]
mod tests {
    use std::{
        collections::HashSet,
        path::{Path, PathBuf},
        sync::{Arc, Mutex},
    };

    use super::*;
    use crate::{install::unpack::UnpackFuture, test_support::TempDir};

    #[derive(Default)]
    struct FakeUnpacker {
        fail: bool,
        create_commit_conflict: bool,
        archives: Mutex<Vec<PathBuf>>,
    }

    impl FakeUnpacker {
        fn failing() -> Self {
            Self {
                fail: true,
                create_commit_conflict: false,
                archives: Mutex::new(Vec::new()),
            }
        }

        fn commit_conflict() -> Self {
            Self {
                fail: false,
                create_commit_conflict: true,
                archives: Mutex::new(Vec::new()),
            }
        }
    }

    impl Unpacker for FakeUnpacker {
        fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
            Box::pin(async move {
                self.archives
                    .lock()
                    .expect("archive list should not be poisoned")
                    .push(archive.to_path_buf());
                if self.fail {
                    eyre::bail!("forced unpack failure");
                }
                tokio::fs::write(dest.join("payload.txt"), b"installed").await?;
                if self.create_commit_conflict {
                    let game_root = dest
                        .parent()
                        .ok_or_else(|| eyre::eyre!("staging dir should have parent"))?;
                    let local_conflict = game_root.join(LOCAL_DIR);
                    tokio::fs::create_dir_all(&local_conflict).await?;
                    tokio::fs::write(local_conflict.join("conflict.txt"), b"conflict").await?;
                }
                Ok(())
            })
        }
    }

    fn write_file(path: &Path, bytes: &[u8]) {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).expect("parent dir should be created");
        }
        std::fs::write(path, bytes).expect("file should be written");
    }

    fn successful_unpacker() -> Arc<dyn Unpacker> {
        Arc::new(FakeUnpacker::default())
    }

    #[tokio::test]
    async fn install_success_promotes_staging_and_clears_intent() {
        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        write_file(&root.join("game.eti"), b"archive");
        write_file(&root.join("version.ini"), b"20250101");

        install(&root, "game", successful_unpacker())
            .await
            .expect("install should succeed");

        assert!(root.join("local").join("payload.txt").is_file());
        assert!(!root.join(".local.installing").exists());
        let intent = read_intent(&root, "game").await;
        assert_eq!(intent.state, InstallIntentState::None);
    }

    #[tokio::test]
    async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        write_file(&root.join("b.eti"), b"archive");
        write_file(&root.join("a.eti"), b"archive");
        write_file(&root.join("version.ini"), b"20250101");
        let unpacker = Arc::new(FakeUnpacker::default());

        install(&root, "game", unpacker.clone())
            .await
            .expect("install should succeed");

        let archives = unpacker
            .archives
            .lock()
            .expect("archive list should not be poisoned")
            .iter()
            .filter_map(|path| path.file_name()?.to_str().map(ToOwned::to_owned))
            .collect::<Vec<_>>();
        assert_eq!(archives, vec!["a.eti", "b.eti"]);
    }

    #[tokio::test]
    async fn update_failure_restores_previous_local() {
        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        write_file(&root.join("game.eti"), b"archive");
        write_file(&root.join("version.ini"), b"20250101");
        write_file(&root.join("local").join("old.txt"), b"old");

        let err = update(&root, "game", Arc::new(FakeUnpacker::failing()))
            .await
            .expect_err("update should fail");

        assert!(err.to_string().contains("forced unpack failure"));
        assert!(root.join("local").join("old.txt").is_file());
        assert!(!root.join(".local.installing").exists());
        assert!(!root.join(".local.backup").exists());
        let intent = read_intent(&root, "game").await;
        assert_eq!(intent.state, InstallIntentState::None);
    }

    #[tokio::test]
    async fn update_commit_rename_failure_restores_previous_local() {
        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        write_file(&root.join("game.eti"), b"archive");
        write_file(&root.join("version.ini"), b"20250101");
        write_file(&root.join("local").join("old.txt"), b"old");

        let err = update(&root, "game", Arc::new(FakeUnpacker::commit_conflict()))
            .await
            .expect_err("update should fail at commit rename");

        assert!(
            err.to_string().contains("failed to promote update"),
            "{err:?}"
        );
        assert_eq!(
            std::fs::read(root.join("local").join("old.txt"))
                .expect("old install should be restored"),
            b"old"
        );
        assert!(!root.join("local").join("conflict.txt").exists());
        assert!(!root.join(".local.installing").exists());
        assert!(!root.join(".local.backup").exists());
        let intent = read_intent(&root, "game").await;
        assert_eq!(intent.state, InstallIntentState::None);
    }

    #[tokio::test]
    async fn update_success_promotes_new_local_and_removes_backup() {
        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        write_file(&root.join("game.eti"), b"archive");
        write_file(&root.join("version.ini"), b"20250101");
        write_file(&root.join("local").join("old.txt"), b"old");

        update(&root, "game", successful_unpacker())
            .await
            .expect("update should succeed");

        assert!(root.join("local").join("payload.txt").is_file());
        assert!(!root.join("local").join("old.txt").exists());
        assert!(!root.join(".local.installing").exists());
        assert!(!root.join(".local.backup").exists());
        let intent = read_intent(&root, "game").await;
        assert_eq!(intent.state, InstallIntentState::None);
    }

    #[tokio::test]
    async fn uninstall_removes_only_local_install() {
        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        write_file(&root.join("game.eti"), b"archive");
        write_file(&root.join("version.ini"), b"20250101");
        write_file(&root.join("local").join("payload.txt"), b"installed");

        uninstall(&root, "game")
            .await
            .expect("uninstall should succeed");

        assert!(!root.join("local").exists());
        assert!(root.join("game.eti").is_file());
        assert!(root.join("version.ini").is_file());
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn uninstall_delete_failure_restores_backup() {
        use std::os::unix::fs::PermissionsExt;

        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        let locked_dir = root.join("local").join("locked");
        write_file(&root.join("version.ini"), b"20250101");
        write_file(&root.join("local").join("old.txt"), b"old");
        write_file(&locked_dir.join("payload.txt"), b"locked");
        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o500))
            .expect("locked dir permissions should be set");

        let _err = uninstall(&root, "game")
            .await
            .expect_err("uninstall should fail while deleting backup");

        for restored_locked_dir in [
            root.join("local").join("locked"),
            root.join(".local.backup").join("locked"),
        ] {
            if restored_locked_dir.exists() {
                std::fs::set_permissions(
                    &restored_locked_dir,
                    std::fs::Permissions::from_mode(0o700),
                )
                .expect("locked dir permissions should be restored for cleanup");
            }
        }

        assert_eq!(
            std::fs::read(root.join("local").join("old.txt"))
                .expect("old install should be restored"),
            b"old"
        );
        assert_eq!(
            std::fs::read(root.join("local").join("locked").join("payload.txt"))
                .expect("locked payload should be restored"),
            b"locked"
        );
        assert!(!root.join(".local.backup").exists());
        let intent = read_intent(&root, "game").await;
        assert_eq!(intent.state, InstallIntentState::None);
    }

    #[derive(Clone)]
    struct RecoveryCase {
        name: &'static str,
        intent_state: InstallIntentState,
        has_local: bool,
        has_installing: bool,
        has_backup: bool,
        expected_local_payload: Option<&'static [u8]>,
    }

    const LOCAL_PAYLOAD: &[u8] = b"local";
    const INSTALLING_PAYLOAD: &[u8] = b"installing";
    const BACKUP_PAYLOAD: &[u8] = b"backup";

    fn seed_recovery_case(root: &Path, case: &RecoveryCase) {
        write_file(&root.join("version.ini"), b"20250101");
        if case.has_local {
            write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
        }
        if case.has_installing {
            write_file(
                &root.join(INSTALLING_DIR).join("payload.txt"),
                INSTALLING_PAYLOAD,
            );
        }
        if case.has_backup {
            write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
        }
    }

    fn assert_recovered_case(root: &Path, case: &RecoveryCase) {
        let local_payload = root.join(LOCAL_DIR).join("payload.txt");
        match case.expected_local_payload {
            Some(expected) => assert_eq!(
                std::fs::read(&local_payload)
                    .unwrap_or_else(|err| panic!("{} local payload: {err}", case.name)),
                expected,
                "{} local payload",
                case.name
            ),
            None => assert!(
                !root.join(LOCAL_DIR).exists(),
                "{} local dir should be absent",
                case.name
            ),
        }
        assert!(
            !root.join(INSTALLING_DIR).exists(),
            "{} installing dir should be absent",
            case.name
        );
        assert!(
            !root.join(BACKUP_DIR).exists(),
            "{} backup dir should be absent",
            case.name
        );
    }

    #[tokio::test]
    async fn recovery_covers_install_matrix_rows() {
        let cases = [
            RecoveryCase {
                name: "installing_commit_landed",
                intent_state: InstallIntentState::Installing,
                has_local: true,
                has_installing: false,
                has_backup: false,
                expected_local_payload: Some(LOCAL_PAYLOAD),
            },
            RecoveryCase {
                name: "installing_staging_only",
                intent_state: InstallIntentState::Installing,
                has_local: false,
                has_installing: true,
                has_backup: false,
                expected_local_payload: None,
            },
            RecoveryCase {
                name: "installing_before_staging",
                intent_state: InstallIntentState::Installing,
                has_local: false,
                has_installing: false,
                has_backup: false,
                expected_local_payload: None,
            },
            RecoveryCase {
                name: "updating_restore_backup",
                intent_state: InstallIntentState::Updating,
                has_local: false,
                has_installing: true,
                has_backup: true,
                expected_local_payload: Some(BACKUP_PAYLOAD),
            },
            RecoveryCase {
                name: "updating_commit_landed_with_stale_dirs",
                intent_state: InstallIntentState::Updating,
                has_local: true,
                has_installing: true,
                has_backup: true,
                expected_local_payload: Some(LOCAL_PAYLOAD),
            },
            RecoveryCase {
                name: "updating_commit_landed_before_backup_cleanup",
                intent_state: InstallIntentState::Updating,
                has_local: true,
                has_installing: false,
                has_backup: true,
                expected_local_payload: Some(LOCAL_PAYLOAD),
            },
            RecoveryCase {
                name: "updating_commit_and_cleanup_landed",
                intent_state: InstallIntentState::Updating,
                has_local: true,
                has_installing: false,
                has_backup: false,
                expected_local_payload: Some(LOCAL_PAYLOAD),
            },
            RecoveryCase {
                name: "uninstalling_delete_backup",
                intent_state: InstallIntentState::Uninstalling,
                has_local: false,
                has_installing: false,
                has_backup: true,
                expected_local_payload: None,
            },
            RecoveryCase {
                name: "uninstalling_already_clean",
                intent_state: InstallIntentState::Uninstalling,
                has_local: false,
                has_installing: false,
                has_backup: false,
                expected_local_payload: None,
            },
            RecoveryCase {
                name: "uninstalling_before_fs_work",
                intent_state: InstallIntentState::Uninstalling,
                has_local: true,
                has_installing: false,
                has_backup: false,
                expected_local_payload: None,
            },
        ];

        for case in cases {
            let temp = TempDir::new("lanspread-install");
            let root = temp.game_root();
            seed_recovery_case(&root, &case);
            write_intent(
                &root,
                &InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
            )
            .await
            .unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));

            recover_game_root(&root, "game")
                .await
                .unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));

            assert_recovered_case(&root, &case);
            let intent = read_intent(&root, "game").await;
            assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
            assert_eq!(
                intent.eti_version.as_deref(),
                Some("20250101"),
                "{}",
                case.name
            );
        }
    }

    #[tokio::test]
    async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        write_file(&root.join(".local.backup").join("user.txt"), b"user");

        recover_game_root(&root, "game")
            .await
            .expect("recovery should succeed");

        assert!(root.join(".local.backup").join("user.txt").is_file());
    }

    #[tokio::test]
    async fn download_recovery_sweeps_reserved_version_files() {
        let temp = TempDir::new("lanspread-install");
        let root = temp.game_root();
        write_file(&root.join(VERSION_TMP_FILE), b"tmp");
        write_file(&root.join(VERSION_DISCARDED_FILE), b"old");

        recover_game_root(&root, "game")
            .await
            .expect("recovery should succeed");

        assert!(!root.join(VERSION_TMP_FILE).exists());
        assert!(!root.join(VERSION_DISCARDED_FILE).exists());
    }

    #[tokio::test]
    async fn startup_recovery_skips_active_game_roots() {
        let temp = TempDir::new("lanspread-install");
        let active_root = temp.path().join("active");
        let inactive_root = temp.path().join("inactive");
        write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
        write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");

        recover_on_startup(temp.path(), &HashSet::from(["active".to_string()]))
            .await
            .expect("recovery should succeed");

        assert!(active_root.join(VERSION_TMP_FILE).is_file());
        assert!(!inactive_root.join(VERSION_TMP_FILE).exists());
    }
}
