//! Command handlers for peer commands.

use std::{
    collections::{HashSet, hash_map::Entry},
    net::SocketAddr,
    path::{Path, PathBuf},
    sync::Arc,
};

use lanspread_db::db::{GameDB, GameFileDescription};
use tokio::sync::{RwLock, mpsc::UnboundedSender};

use crate::{
    ActiveOperation,
    ActiveOperationKind,
    InstallOperation,
    PeerEvent,
    context::{Ctx, OperationGuard, OperationKind},
    download::download_game_files,
    events,
    identity::FEATURE_LIBRARY_DELTA,
    install,
    local_games::{
        LocalLibraryScan,
        game_from_summary,
        get_game_file_descriptions,
        local_dir_is_directory,
        local_download_available,
        rescan_local_game,
        scan_local_library,
        version_ini_is_regular_file,
    },
    network::{announce_games_to_peer, request_game_details_from_peer, send_library_delta},
    peer_db::PeerGameDB,
    remote_peer::ensure_peer_id_for_addr,
};

// =============================================================================
// Command handlers
// =============================================================================

/// Handles the `ListGames` command.
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
    log::info!("ListGames command received");
    events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
}

/// Tries to serve a game from local files.
async fn try_serve_local_game(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    id: &str,
) -> bool {
    let game_dir = { ctx.game_dir.read().await.clone() };

    let active_operations = ctx.active_operations.read().await;
    let catalog = ctx.catalog.read().await;
    if !local_download_available(&game_dir, id, &active_operations, &catalog).await {
        return false;
    }
    drop(active_operations);
    drop(catalog);

    match get_game_file_descriptions(id, &game_dir).await {
        Ok(file_descriptions) => {
            log::info!("Serving game {id} from local files");
            if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
                id: id.to_string(),
                file_descriptions,
            }) {
                log::error!("Failed to send GotGameFiles event: {e}");
            }
            true
        }
        Err(e) => {
            log::error!("Failed to enumerate local file descriptions for {id}: {e}");
            false
        }
    }
}

/// Handles the `GetGame` command.
pub async fn handle_get_game_command(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    id: String,
) {
    if try_serve_local_game(ctx, tx_notify_ui, &id).await {
        return;
    }

    log::info!("Requesting game from peers: {id}");
    let peers = { ctx.peer_game_db.read().await.peers_with_game(&id) };
    if peers.is_empty() {
        log::warn!("No peers have game {id}");
        if let Err(e) = tx_notify_ui.send(PeerEvent::NoPeersHaveGame { id: id.clone() }) {
            log::error!("Failed to send NoPeersHaveGame event: {e}");
        }
        return;
    }

    let peer_game_db = ctx.peer_game_db.clone();
    let tx_notify_ui = tx_notify_ui.clone();
    ctx.task_tracker.spawn(async move {
        let mut fetched_any = false;
        for peer_addr in peers {
            match request_game_details_and_update(peer_addr, &id, peer_game_db.clone()).await {
                Ok(_) => {
                    log::info!("Fetched game file list for {id} from peer {peer_addr}");
                    fetched_any = true;
                }
                Err(e) => {
                    log::error!("Failed to fetch game files for {id} from {peer_addr}: {e}");
                }
            }
        }

        if fetched_any {
            let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) };

            if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles {
                id: id.clone(),
                file_descriptions: aggregated_files,
            }) {
                log::error!("Failed to send GotGameFiles event: {e}");
            }
        } else {
            log::warn!("Failed to retrieve game files for {id} from any peer");
        }
    });
}

/// Requests game details from a peer and updates the peer game database.
async fn request_game_details_and_update(
    peer_addr: SocketAddr,
    game_id: &str,
    peer_game_db: Arc<RwLock<PeerGameDB>>,
) -> eyre::Result<Vec<GameFileDescription>> {
    let (file_descriptions, _) = request_game_details_from_peer(peer_addr, game_id).await?;
    let peer_id = ensure_peer_id_for_addr(&peer_game_db, peer_addr).await;

    {
        let mut db = peer_game_db.write().await;
        db.update_peer_game_files(&peer_id, game_id, file_descriptions.clone());
    }

    Ok(file_descriptions)
}

/// Handles the `DownloadGameFiles` command.
#[allow(clippy::too_many_lines)]
pub async fn handle_download_game_files_command(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    id: String,
    file_descriptions: Vec<GameFileDescription>,
) {
    log::info!("Got PeerCommand::DownloadGameFiles");
    let games_folder = { ctx.game_dir.read().await.clone() };

    // Use majority validation to get trusted file descriptions and peer whitelist
    let (validated_descriptions, peer_whitelist, file_peer_map) = {
        match ctx
            .peer_game_db
            .read()
            .await
            .validate_file_sizes_majority(&id)
        {
            Ok((files, peers, file_peer_map)) => {
                log::info!(
                    "Majority validation: {} validated files, {} trusted peers for game {id}",
                    files.len(),
                    peers.len()
                );
                (files, peers, file_peer_map)
            }
            Err(e) => {
                log::error!("File size majority validation failed for {id}: {e}");
                if let Err(send_err) =
                    tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() })
                {
                    log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
                }
                return;
            }
        }
    };

    let resolved_descriptions = if file_descriptions.is_empty() {
        validated_descriptions
    } else {
        // If user provided specific descriptions, still validate them against majority
        // but keep user's selection (they might want specific files)
        file_descriptions
    };

    if resolved_descriptions.is_empty() {
        log::error!(
            "No validated file descriptions available to download game {id}; request metadata first"
        );
        return;
    }

    let local_dl_available = {
        let active_operations = ctx.active_operations.read().await;
        let catalog = ctx.catalog.read().await;
        local_download_available(&games_folder, &id, &active_operations, &catalog).await
    };

    if peer_whitelist.is_empty() {
        if local_dl_available {
            log::info!("Using locally downloaded files for game {id}; skipping peer transfer");
            if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { id: id.clone() })
            {
                log::error!("Failed to send DownloadGameFilesBegin event: {e}");
            }
            if let Err(e) =
                tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.clone() })
            {
                log::error!("Failed to send DownloadGameFilesFinished event: {e}");
            }
            spawn_install_operation(ctx, tx_notify_ui, id.clone());
        } else {
            log::error!("No trusted peers available after majority validation for game {id}");
        }
        return;
    }

    {
        let mut in_progress = ctx.active_operations.write().await;
        match in_progress.entry(id.clone()) {
            Entry::Vacant(entry) => {
                entry.insert(OperationKind::Downloading);
            }
            Entry::Occupied(_) => {
                log::warn!("Operation for {id} already in progress; ignoring new download request");
                return;
            }
        }
    }

    let active_operations = ctx.active_operations.clone();
    let active_downloads = ctx.active_downloads.clone();
    let tx_notify_ui_clone = tx_notify_ui.clone();
    let download_id = id.clone();
    let cancel_token = ctx.shutdown.child_token();
    let ctx_clone = ctx.clone();

    ctx.active_downloads
        .write()
        .await
        .insert(id, cancel_token.clone());

    ctx.task_tracker.spawn(async move {
        let result = {
            let _download_state_guard =
                OperationGuard::download(download_id.clone(), active_operations, active_downloads);

            download_game_files(
                &download_id,
                resolved_descriptions,
                games_folder,
                peer_whitelist,
                file_peer_map,
                tx_notify_ui_clone.clone(),
                cancel_token,
            )
            .await
        };

        match result {
            Ok(()) => {
                run_install_operation(&ctx_clone, &tx_notify_ui_clone, download_id).await;
            }
            Err(e) => {
                log::error!("Download failed for {download_id}: {e}");
            }
        }
    });
}

/// Handles the `InstallGame` command.
pub async fn handle_install_game_command(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    id: String,
) {
    spawn_install_operation(ctx, tx_notify_ui, id);
}

/// Handles the `UninstallGame` command.
pub async fn handle_uninstall_game_command(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    id: String,
) {
    let ctx = ctx.clone();
    let tx_notify_ui = tx_notify_ui.clone();
    ctx.task_tracker.clone().spawn(async move {
        run_uninstall_operation(&ctx, &tx_notify_ui, id).await;
    });
}

fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
    let ctx = ctx.clone();
    let tx_notify_ui = tx_notify_ui.clone();
    ctx.task_tracker.clone().spawn(async move {
        run_install_operation(&ctx, &tx_notify_ui, id).await;
    });
}

async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
    if !catalog_contains(ctx, &id).await {
        log::warn!("Ignoring install command for non-catalog game {id}");
        return;
    }

    let game_root = { ctx.game_dir.read().await.join(&id) };
    if !version_ini_is_regular_file(&game_root).await {
        log::warn!("Ignoring install command for {id}: version.ini sentinel is absent");
        events::send(tx_notify_ui, PeerEvent::InstallGameFailed { id });
        return;
    }

    let local_present = local_dir_is_directory(&game_root).await;
    let operation = if local_present {
        InstallOperation::Updating
    } else {
        InstallOperation::Installing
    };
    let operation_kind = match operation {
        InstallOperation::Installing => OperationKind::Installing,
        InstallOperation::Updating => OperationKind::Updating,
    };

    if !begin_operation(ctx, &id, operation_kind).await {
        log::warn!("Operation for {id} already in progress; ignoring install command");
        return;
    }

    let result = {
        let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
        events::send(
            tx_notify_ui,
            PeerEvent::InstallGameBegin {
                id: id.clone(),
                operation,
            },
        );

        match operation {
            InstallOperation::Installing => {
                install::install(&game_root, &id, ctx.unpacker.clone()).await
            }
            InstallOperation::Updating => {
                install::update(&game_root, &id, ctx.unpacker.clone()).await
            }
        }
    };

    match result {
        Ok(()) => {
            events::send(
                tx_notify_ui,
                PeerEvent::InstallGameFinished { id: id.clone() },
            );
            if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
                log::error!("Failed to refresh local library after install: {err}");
            }
        }
        Err(err) => {
            log::error!("Install operation failed for {id}: {err}");
            events::send(
                tx_notify_ui,
                PeerEvent::InstallGameFailed { id: id.clone() },
            );
            if let Err(refresh_err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
                log::error!("Failed to refresh local library after install failure: {refresh_err}");
            }
        }
    }
}

async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
    if !catalog_contains(ctx, &id).await {
        log::warn!("Ignoring uninstall command for non-catalog game {id}");
        return;
    }

    if !begin_operation(ctx, &id, OperationKind::Uninstalling).await {
        log::warn!("Operation for {id} already in progress; ignoring uninstall command");
        return;
    }

    let game_root = { ctx.game_dir.read().await.join(&id) };
    let result = {
        let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
        events::send(
            tx_notify_ui,
            PeerEvent::UninstallGameBegin { id: id.clone() },
        );

        install::uninstall(&game_root, &id).await
    };

    match result {
        Ok(()) => {
            events::send(
                tx_notify_ui,
                PeerEvent::UninstallGameFinished { id: id.clone() },
            );
        }
        Err(err) => {
            log::error!("Uninstall operation failed for {id}: {err}");
            events::send(
                tx_notify_ui,
                PeerEvent::UninstallGameFailed { id: id.clone() },
            );
        }
    }

    if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
        log::error!("Failed to refresh local library after uninstall: {err}");
    }
}

async fn begin_operation(ctx: &Ctx, id: &str, operation: OperationKind) -> bool {
    let mut active_operations = ctx.active_operations.write().await;
    match active_operations.entry(id.to_string()) {
        Entry::Vacant(entry) => {
            entry.insert(operation);
            true
        }
        Entry::Occupied(_) => false,
    }
}

async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
    ctx.catalog.read().await.contains(id)
}

/// Handles the `SetGameDir` command.
pub async fn handle_set_game_dir_command(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    game_dir: PathBuf,
) {
    let current_game_dir = ctx.game_dir.read().await.clone();
    if current_game_dir == game_dir {
        log::info!(
            "Game directory {} unchanged; refreshing without recovery",
            game_dir.display()
        );
        let tx_notify_ui = tx_notify_ui.clone();
        let ctx_clone = ctx.clone();
        ctx.task_tracker.spawn(async move {
            if let Err(err) = refresh_local_library(&ctx_clone, &tx_notify_ui).await {
                log::error!("Failed to refresh local game database: {err}");
            }
        });
        return;
    }

    let active_ids = active_operation_ids(ctx).await;
    if !active_ids.is_empty() {
        log::warn!(
            "Rejecting game directory change to {} while operations are active for: {}",
            game_dir.display(),
            active_ids.into_iter().collect::<Vec<_>>().join(", ")
        );
        return;
    }

    *ctx.game_dir.write().await = game_dir.clone();
    log::info!("Game directory set to: {}", game_dir.display());

    let tx_notify_ui = tx_notify_ui.clone();
    let ctx_clone = ctx.clone();

    ctx.task_tracker.spawn(async move {
        match load_local_library(&ctx_clone, &tx_notify_ui).await {
            Ok(()) => log::info!("Local game database loaded successfully"),
            Err(e) => {
                log::error!("Failed to load local game database: {e}");
            }
        }
    });
}

/// Loads the configured local library and announces the result.
pub async fn load_local_library(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
) -> eyre::Result<()> {
    let game_dir = { ctx.game_dir.read().await.clone() };
    let active_ids = active_operation_ids(ctx).await;
    install::recover_on_startup(&game_dir, &active_ids).await?;
    scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir).await
}

async fn refresh_local_library(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
) -> eyre::Result<()> {
    let game_dir = { ctx.game_dir.read().await.clone() };
    scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir).await
}

async fn scan_and_announce_local_library(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    game_dir: &Path,
) -> eyre::Result<()> {
    let catalog = ctx.catalog.read().await.clone();
    let scan = scan_local_library(game_dir, &catalog).await?;
    update_and_announce_games(ctx, tx_notify_ui, scan).await;
    Ok(())
}

async fn refresh_local_game(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    id: &str,
) -> eyre::Result<()> {
    let game_dir = { ctx.game_dir.read().await.clone() };
    let catalog = ctx.catalog.read().await.clone();
    let scan = rescan_local_game(&game_dir, &catalog, id).await?;
    update_and_announce_games(ctx, tx_notify_ui, scan).await;
    Ok(())
}

async fn active_operation_ids(ctx: &Ctx) -> HashSet<String> {
    ctx.active_operations.read().await.keys().cloned().collect()
}

/// Handles the `GetPeerCount` command.
pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
    log::info!("GetPeerCount command received");
    events::emit_peer_count(&ctx.peer_game_db, tx_notify_ui).await;
}

// =============================================================================
// Game announcement helpers
// =============================================================================

/// Updates the local game database and announces changes to peers.
pub async fn update_and_announce_games(
    ctx: &Ctx,
    tx_notify_ui: &UnboundedSender<PeerEvent>,
    scan: LocalLibraryScan,
) {
    let LocalLibraryScan {
        mut game_db,
        mut summaries,
        revision,
    } = scan;

    let active_operations = active_operation_snapshot(ctx).await;
    if !active_operations.is_empty() {
        let previous = ctx.local_library.read().await.games.clone();
        for id in active_operations.iter().map(|operation| &operation.id) {
            if let Some(summary) = previous.get(id) {
                summaries.insert(id.clone(), summary.clone());
            } else {
                summaries.remove(id);
            }
        }
        game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
    }

    let delta = {
        let mut library_guard = ctx.local_library.write().await;
        library_guard.update_from_scan(summaries, revision)
    };

    {
        let mut db_guard = ctx.local_game_db.write().await;
        *db_guard = Some(game_db.clone());
    }

    let all_games = game_db.all_games().into_iter().cloned().collect::<Vec<_>>();

    if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated {
        games: all_games.clone(),
        active_operations,
    }) {
        log::error!("Failed to send LocalGamesUpdated event: {e}");
    }

    let Some(delta) = delta else {
        return;
    };

    let peer_targets = {
        let db = ctx.peer_game_db.read().await;
        db.peer_identities()
            .into_iter()
            .map(|(peer_id, addr)| {
                let features = db.peer_features(&peer_id);
                (peer_id, addr, features)
            })
            .collect::<Vec<_>>()
    };

    for (_peer_id, peer_addr, features) in peer_targets {
        if features
            .iter()
            .any(|feature| feature == FEATURE_LIBRARY_DELTA)
        {
            let delta = delta.clone();
            ctx.task_tracker.spawn(async move {
                if let Err(e) = send_library_delta(peer_addr, delta).await {
                    log::warn!("Failed to send library delta to {peer_addr}: {e}");
                }
            });
        } else {
            let games_clone = all_games.clone();
            ctx.task_tracker.spawn(async move {
                if let Err(e) = announce_games_to_peer(peer_addr, games_clone).await {
                    log::warn!("Failed to announce games to {peer_addr}: {e}");
                }
            });
        }
    }
}

async fn active_operation_snapshot(ctx: &Ctx) -> Vec<ActiveOperation> {
    let active_operations = ctx.active_operations.read().await;
    let mut snapshot = active_operations
        .iter()
        .map(|(id, operation)| ActiveOperation {
            id: id.clone(),
            operation: active_operation_kind(*operation),
        })
        .collect::<Vec<_>>();
    snapshot.sort_by(|left, right| left.id.cmp(&right.id));
    snapshot
}

fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
    match operation {
        OperationKind::Downloading => ActiveOperationKind::Downloading,
        OperationKind::Installing => ActiveOperationKind::Installing,
        OperationKind::Updating => ActiveOperationKind::Updating,
        OperationKind::Uninstalling => ActiveOperationKind::Uninstalling,
    }
}

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

    use tokio::sync::mpsc;
    use tokio_util::{sync::CancellationToken, task::TaskTracker};

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

    struct FakeUnpacker;

    impl Unpacker for FakeUnpacker {
        fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
            Box::pin(async move {
                tokio::fs::write(dest.join("payload.txt"), b"installed").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 test_ctx(game_dir: PathBuf) -> Ctx {
        Ctx::new(
            Arc::new(RwLock::new(PeerGameDB::new())),
            "peer".to_string(),
            game_dir,
            Arc::new(FakeUnpacker),
            CancellationToken::new(),
            TaskTracker::new(),
            Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
        )
    }

    async fn recv_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
        tokio::time::timeout(Duration::from_secs(1), rx.recv())
            .await
            .expect("event should arrive")
            .expect("event channel should remain open")
    }

    fn assert_local_update(event: PeerEvent, installed: bool, downloaded: bool) {
        let PeerEvent::LocalGamesUpdated {
            games,
            active_operations,
        } = event
        else {
            panic!("expected LocalGamesUpdated");
        };
        assert!(
            active_operations.is_empty(),
            "settled local update should not report active operations"
        );
        let game = games
            .iter()
            .find(|game| game.id == "game")
            .expect("game should be announced");
        assert_eq!(game.installed, installed);
        assert_eq!(game.downloaded, downloaded);
    }

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

        let ctx = test_ctx(temp.path().to_path_buf());
        ctx.active_operations
            .write()
            .await
            .insert("game".to_string(), OperationKind::Installing);
        let (tx, mut rx) = mpsc::unbounded_channel();
        let catalog = ctx.catalog.read().await.clone();
        let scan = scan_local_library(temp.path(), &catalog)
            .await
            .expect("scan should succeed");

        update_and_announce_games(&ctx, &tx, scan).await;

        let PeerEvent::LocalGamesUpdated {
            games,
            active_operations,
        } = recv_event(&mut rx).await
        else {
            panic!("expected LocalGamesUpdated");
        };
        assert!(
            games.is_empty(),
            "active game should keep its previous announced state"
        );
        assert_eq!(
            active_operations,
            vec![ActiveOperation {
                id: "game".to_string(),
                operation: ActiveOperationKind::Installing,
            }]
        );
    }

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

        let ctx = test_ctx(temp.path().to_path_buf());
        let (tx, mut rx) = mpsc::unbounded_channel();
        let catalog = ctx.catalog.read().await.clone();

        let scan = scan_local_library(temp.path(), &catalog)
            .await
            .expect("first scan should succeed");
        update_and_announce_games(&ctx, &tx, scan).await;
        assert_local_update(recv_event(&mut rx).await, false, true);

        ctx.active_operations
            .write()
            .await
            .insert("game".to_string(), OperationKind::Updating);
        let scan = scan_local_library(temp.path(), &catalog)
            .await
            .expect("second scan should succeed");
        update_and_announce_games(&ctx, &tx, scan).await;

        let PeerEvent::LocalGamesUpdated {
            active_operations, ..
        } = recv_event(&mut rx).await
        else {
            panic!("expected LocalGamesUpdated");
        };
        assert_eq!(
            active_operations,
            vec![ActiveOperation {
                id: "game".to_string(),
                operation: ActiveOperationKind::Updating,
            }]
        );
    }

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

        let ctx = test_ctx(temp.path().to_path_buf());
        let (tx, mut rx) = mpsc::unbounded_channel();

        run_install_operation(&ctx, &tx, "game".to_string()).await;

        match recv_event(&mut rx).await {
            PeerEvent::InstallGameBegin { id, operation } => {
                assert_eq!(id, "game");
                assert_eq!(operation, InstallOperation::Installing);
            }
            _ => panic!("expected InstallGameBegin"),
        }
        assert!(matches!(
            recv_event(&mut rx).await,
            PeerEvent::InstallGameFinished { id } if id == "game"
        ));
        assert!(ctx.active_operations.read().await.is_empty());
        assert_local_update(recv_event(&mut rx).await, true, true);
    }

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

        let ctx = test_ctx(temp.path().to_path_buf());
        let (tx, mut rx) = mpsc::unbounded_channel();

        run_install_operation(&ctx, &tx, "game".to_string()).await;

        match recv_event(&mut rx).await {
            PeerEvent::InstallGameBegin { id, operation } => {
                assert_eq!(id, "game");
                assert_eq!(operation, InstallOperation::Updating);
            }
            _ => panic!("expected InstallGameBegin"),
        }
        assert!(matches!(
            recv_event(&mut rx).await,
            PeerEvent::InstallGameFinished { id } if id == "game"
        ));
        assert!(ctx.active_operations.read().await.is_empty());
        assert_local_update(recv_event(&mut rx).await, true, true);
    }

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

        let ctx = test_ctx(temp.path().to_path_buf());
        let (tx, mut rx) = mpsc::unbounded_channel();

        run_uninstall_operation(&ctx, &tx, "game".to_string()).await;

        assert!(matches!(
            recv_event(&mut rx).await,
            PeerEvent::UninstallGameBegin { id } if id == "game"
        ));
        assert!(matches!(
            recv_event(&mut rx).await,
            PeerEvent::UninstallGameFinished { id } if id == "game"
        ));
        assert!(ctx.active_operations.read().await.is_empty());
        assert_local_update(recv_event(&mut rx).await, false, true);
    }

    #[tokio::test]
    async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() {
        let current = TempDir::new("lanspread-handler-current-dir");
        let next = TempDir::new("lanspread-handler-next-dir");
        let ctx = test_ctx(current.path().to_path_buf());
        ctx.active_operations
            .write()
            .await
            .insert("game".to_string(), OperationKind::Downloading);
        let (tx, _rx) = mpsc::unbounded_channel();

        handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;

        assert_eq!(*ctx.game_dir.read().await, current.path());
    }

    #[tokio::test]
    async fn same_path_set_game_dir_refreshes_without_recovery() {
        let temp = TempDir::new("lanspread-handler-same-dir");
        write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp");
        let ctx = test_ctx(temp.path().to_path_buf());
        let (tx, _rx) = mpsc::unbounded_channel();

        handle_set_game_dir_command(&ctx, &tx, temp.path().to_path_buf()).await;
        ctx.task_tracker.close();
        ctx.task_tracker.wait().await;

        assert!(temp.game_root().join(".version.ini.tmp").is_file());
    }

    #[tokio::test]
    async fn path_changing_set_game_dir_runs_recovery() {
        let current = TempDir::new("lanspread-handler-old-dir");
        let next = TempDir::new("lanspread-handler-new-dir");
        write_file(&next.game_root().join(".version.ini.tmp"), b"tmp");
        let ctx = test_ctx(current.path().to_path_buf());
        let (tx, _rx) = mpsc::unbounded_channel();

        handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
        ctx.task_tracker.close();
        ctx.task_tracker.wait().await;

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