//! Local game scanning and database management.

use std::{
    collections::{HashMap, HashSet},
    hash::{Hash, Hasher},
    io::ErrorKind,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_proto::{Availability, GameSummary};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;

use crate::{context::OperationKind, error::PeerError};

// =============================================================================
// Local directory helpers
// =============================================================================

#[cfg(target_os = "windows")]
pub fn is_local_dir_name(name: &str) -> bool {
    name.eq_ignore_ascii_case("local")
}

#[cfg(not(target_os = "windows"))]
pub fn is_local_dir_name(name: &str) -> bool {
    name == "local"
}

/// Checks if `local/` is a committed install directory.
pub async fn local_dir_is_directory(path: &Path) -> bool {
    let local_dir = path.join("local");
    tokio::fs::metadata(&local_dir)
        .await
        .is_ok_and(|metadata| metadata.is_dir())
}

/// Checks if the root-level `version.ini` sentinel exists as a regular file.
pub async fn version_ini_is_regular_file(game_path: &Path) -> bool {
    let version_path = game_path.join("version.ini");
    tokio::fs::metadata(&version_path)
        .await
        .is_ok_and(|metadata| metadata.is_file())
}

/// Checks if a game is available for download locally.
pub async fn local_download_available(
    game_dir: &Path,
    game_id: &str,
    active_operations: &HashMap<String, OperationKind>,
    catalog: &HashSet<String>,
) -> bool {
    if !catalog.contains(game_id) {
        log::debug!("Not serving game {game_id} locally because it is not in the catalog");
        return false;
    }

    if active_operations.contains_key(game_id) {
        log::debug!("Not serving game {game_id} locally because an operation is active");
        return false;
    }

    let game_path = game_dir.join(game_id);
    version_ini_is_regular_file(game_path.as_path()).await
}

// =============================================================================
// Local library index and scanning
// =============================================================================

const LIBRARY_INDEX_DIR: &str = ".lanspread";
const LIBRARY_INDEX_FILE: &str = "library_index.json";
const INTENT_LOG_FILE: &str = ".lanspread.json";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";

#[derive(Debug, Clone, Serialize, Deserialize)]
struct LibraryIndex {
    revision: u64,
    games: HashMap<String, GameIndexEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct GameIndexEntry {
    summary: GameSummary,
    fingerprint: GameFingerprint,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct GameFingerprint {
    eti_files: Vec<EtiFingerprint>,
    version_mtime: Option<u64>,
    local_dir_present: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct EtiFingerprint {
    name: String,
    size: u64,
    mtime: Option<u64>,
}

#[derive(Debug, Clone)]
pub struct LocalLibraryScan {
    pub game_db: GameDB,
    pub summaries: HashMap<String, GameSummary>,
    pub revision: u64,
}

fn library_index_path(game_dir: &Path) -> PathBuf {
    game_dir.join(LIBRARY_INDEX_DIR).join(LIBRARY_INDEX_FILE)
}

fn library_index_tmp_path(path: &Path) -> PathBuf {
    let Some(file_name) = path.file_name() else {
        return path.with_extension("tmp");
    };

    let mut tmp_name = file_name.to_os_string();
    tmp_name.push(".tmp");
    path.with_file_name(tmp_name)
}

async fn sweep_stale_library_index_tmp(path: &Path) {
    let tmp_path = library_index_tmp_path(path);
    match tokio::fs::remove_file(&tmp_path).await {
        Ok(()) => log::debug!(
            "Removed stale library index temp file {}",
            tmp_path.display()
        ),
        Err(err) if err.kind() == ErrorKind::NotFound => {}
        Err(err) => log::warn!(
            "Failed to remove stale library index temp file {}: {err}",
            tmp_path.display()
        ),
    }
}

async fn load_library_index(path: &Path) -> LibraryIndex {
    sweep_stale_library_index_tmp(path).await;

    let data = match tokio::fs::read_to_string(path).await {
        Ok(data) => data,
        Err(err) => {
            if err.kind() != ErrorKind::NotFound {
                log::warn!("Failed to read library index {}: {err}", path.display());
            }
            return LibraryIndex {
                revision: 0,
                games: HashMap::new(),
            };
        }
    };

    match serde_json::from_str(&data) {
        Ok(index) => index,
        Err(err) => {
            log::warn!("Failed to parse library index {}: {err}", path.display());
            LibraryIndex {
                revision: 0,
                games: HashMap::new(),
            }
        }
    }
}

async fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> {
    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }
    let data = serde_json::to_vec_pretty(index)?;
    let tmp_path = library_index_tmp_path(path);

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

    tokio::fs::rename(&tmp_path, path).await?;
    sync_parent_dir(path)?;
    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(())
}

fn system_time_to_secs(time: SystemTime) -> u64 {
    time.duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

fn is_root_eti_name(name: &str) -> bool {
    Path::new(name)
        .extension()
        .is_some_and(|extension| extension == "eti")
}

async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result<Vec<EtiFingerprint>> {
    let mut entries = match tokio::fs::read_dir(game_path).await {
        Ok(entries) => entries,
        Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
        Err(err) => return Err(err.into()),
    };

    let mut eti_files = Vec::new();
    while let Some(entry) = entries.next_entry().await? {
        let file_type = entry.file_type().await?;
        if !file_type.is_file() {
            continue;
        }

        let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
            continue;
        };
        if !is_root_eti_name(&name) {
            continue;
        }

        let metadata = entry.metadata().await?;
        eti_files.push(EtiFingerprint {
            name,
            size: metadata.len(),
            mtime: metadata.modified().ok().map(system_time_to_secs),
        });
    }

    eti_files.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(eti_files)
}

async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint> {
    let eti_files = root_eti_fingerprints(game_path).await?;

    let version_path = game_path.join("version.ini");
    let version_mtime = match tokio::fs::metadata(&version_path).await {
        Ok(metadata) if metadata.is_file() => metadata.modified().ok().map(system_time_to_secs),
        Err(_) | Ok(_) => None,
    };

    let local_dir_present = local_dir_is_directory(game_path).await;

    Ok(GameFingerprint {
        eti_files,
        version_mtime,
        local_dir_present,
    })
}

pub fn is_ignored_game_root_name(name: &str) -> bool {
    name == LIBRARY_INDEX_DIR
}

fn is_reserved_transient_name(name: &str) -> bool {
    name.starts_with(".local.")
        || name == VERSION_TMP_FILE
        || name == VERSION_DISCARDED_FILE
        || name == INTENT_LOG_FILE
        || name == LIBRARY_INDEX_DIR
}

fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
    if entry.depth() != 1 {
        return false;
    }

    if entry.file_type().is_dir() && entry.file_name().to_str().is_some_and(is_local_dir_name) {
        return true;
    }

    if let Some(name) = entry.file_name().to_str() {
        if is_reserved_transient_name(name) {
            return true;
        }
        if entry.file_type().is_dir() && name == ".sync" {
            return true;
        }
        if entry.file_type().is_file() && name == ".softlan_game_installed" {
            return true;
        }
    }

    false
}

async fn scan_game_descriptions(
    game_id: &str,
    game_dir: &Path,
) -> Result<Vec<GameFileDescription>, PeerError> {
    let base_dir = game_dir;
    let game_path = base_dir.join(game_id);

    if !game_path.exists() {
        return Err(PeerError::Other(eyre::eyre!(
            "Game directory does not exist: {}",
            game_path.display()
        )));
    }

    let mut file_descriptions = Vec::new();

    for entry in walkdir::WalkDir::new(&game_path)
        .into_iter()
        .filter_entry(|entry| !should_skip_root_entry(entry))
        .filter_map(std::result::Result::ok)
    {
        let relative_path = match entry.path().strip_prefix(base_dir) {
            Ok(path) => path.to_string_lossy().to_string(),
            Err(e) => {
                log::error!(
                    "Failed to get relative path for {}: {}",
                    entry.path().display(),
                    e
                );
                continue;
            }
        };

        let is_dir = entry.file_type().is_dir();
        let size = if is_dir {
            0
        } else {
            match tokio::fs::metadata(entry.path()).await {
                Ok(metadata) => metadata.len(),
                Err(e) => {
                    log::error!("Failed to read metadata for {relative_path}: {e}");
                    return Err(PeerError::FileSizeDetermination {
                        path: relative_path.clone(),
                        source: e,
                    });
                }
            }
        };

        let file_desc = GameFileDescription {
            game_id: game_id.to_string(),
            relative_path,
            is_dir,
            size,
        };

        file_descriptions.push(file_desc);
    }

    Ok(file_descriptions)
}

fn manifest_hash(file_descriptions: &[GameFileDescription]) -> u64 {
    let mut entries: Vec<_> = file_descriptions
        .iter()
        .filter(|desc| !desc.is_dir)
        .map(|desc| (&desc.relative_path, desc.size, desc.is_dir))
        .collect();
    entries.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(&b.1)));

    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    for (path, size, is_dir) in entries {
        path.hash(&mut hasher);
        size.hash(&mut hasher);
        is_dir.hash(&mut hasher);
    }
    hasher.finish()
}

async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result<GameSummary, PeerError> {
    let game_path = game_dir.join(game_id);
    let downloaded = version_ini_is_regular_file(&game_path).await;
    let installed = local_dir_is_directory(&game_path).await;

    let eti_version = if downloaded {
        match lanspread_db::db::read_version_from_ini(&game_path) {
            Ok(version) => version,
            Err(e) => {
                log::warn!("Failed to read version.ini for downloaded game {game_id}: {e}");
                None
            }
        }
    } else {
        None
    };

    let file_descriptions = scan_game_descriptions(game_id, game_dir).await?;
    let total_size = file_descriptions
        .iter()
        .filter(|desc| !desc.is_dir)
        .map(|desc| desc.size)
        .sum();
    let manifest_hash = manifest_hash(&file_descriptions);
    let availability = if downloaded {
        Availability::Ready
    } else {
        Availability::LocalOnly
    };

    Ok(GameSummary {
        id: game_id.to_string(),
        name: game_id.to_string(),
        size: total_size,
        downloaded,
        installed,
        eti_version,
        manifest_hash,
        availability,
    })
}

pub(crate) fn game_from_summary(summary: &GameSummary) -> Game {
    Game {
        id: summary.id.clone(),
        name: summary.name.clone(),
        description: String::new(),
        release_year: String::new(),
        publisher: String::new(),
        max_players: 1,
        version: "1.0".to_string(),
        genre: String::new(),
        size: summary.size,
        downloaded: summary.downloaded,
        installed: summary.installed,
        availability: summary.availability.clone(),
        eti_game_version: summary.eti_version.clone(),
        local_version: summary.eti_version.clone(),
        peer_count: 0,
    }
}

struct IndexUpdate {
    summary: Option<GameSummary>,
    changed: bool,
}

async fn update_index_for_game(
    game_root: &Path,
    game_id: &str,
    catalog: &HashSet<String>,
    index: &mut LibraryIndex,
) -> eyre::Result<IndexUpdate> {
    if !catalog.contains(game_id) {
        return Ok(IndexUpdate {
            summary: None,
            changed: index.games.remove(game_id).is_some(),
        });
    }

    let game_path = game_root.join(game_id);
    let fingerprint = fingerprint_game_dir(&game_path).await?;

    if fingerprint.version_mtime.is_none()
        && !fingerprint.local_dir_present
        && fingerprint.eti_files.is_empty()
    {
        return Ok(IndexUpdate {
            summary: None,
            changed: index.games.remove(game_id).is_some(),
        });
    }

    let mut changed = false;
    let summary = match index.games.get(game_id) {
        Some(entry) if entry.fingerprint == fingerprint => entry.summary.clone(),
        _ => {
            changed = true;
            build_game_summary(game_root, game_id).await?
        }
    };

    if index
        .games
        .get(game_id)
        .is_some_and(|entry| entry.summary.manifest_hash != summary.manifest_hash)
    {
        changed = true;
    }

    index.games.insert(
        game_id.to_string(),
        GameIndexEntry {
            summary: summary.clone(),
            fingerprint,
        },
    );

    Ok(IndexUpdate {
        summary: Some(summary),
        changed,
    })
}

fn empty_scan() -> LocalLibraryScan {
    LocalLibraryScan {
        game_db: GameDB::empty(),
        summaries: HashMap::new(),
        revision: 0,
    }
}

fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
    let summaries = index
        .games
        .iter()
        .map(|(id, entry)| (id.clone(), entry.summary.clone()))
        .collect::<HashMap<_, _>>();
    let games = index
        .games
        .values()
        .map(|entry| game_from_summary(&entry.summary))
        .collect::<Vec<_>>();

    LocalLibraryScan {
        game_db: GameDB::from(games),
        summaries,
        revision: index.revision,
    }
}

// =============================================================================
// Game database loading
// =============================================================================

/// Scans the local game directory and returns summaries plus a game database.
pub async fn scan_local_library(
    game_dir: impl AsRef<Path>,
    catalog: &HashSet<String>,
) -> eyre::Result<LocalLibraryScan> {
    let game_path = game_dir.as_ref();

    let metadata = match tokio::fs::metadata(game_path).await {
        Ok(metadata) => metadata,
        Err(err) => {
            if err.kind() == ErrorKind::NotFound {
                log::warn!(
                    "Local game directory {} missing; reporting empty game database",
                    game_path.display()
                );
                return Ok(empty_scan());
            }
            return Err(err.into());
        }
    };

    if !metadata.is_dir() {
        log::warn!(
            "Configured game directory {} is not a directory; reporting empty game database",
            game_path.display()
        );
        return Ok(empty_scan());
    }

    let index_path = library_index_path(game_path);
    let mut index = load_library_index(&index_path).await;
    let mut seen_ids = HashSet::new();
    let mut summaries = HashMap::new();
    let mut games = Vec::new();
    let mut changed = false;

    let mut entries = tokio::fs::read_dir(game_path).await?;
    while let Some(entry) = entries.next_entry().await? {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }

        let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else {
            continue;
        };
        if is_ignored_game_root_name(game_id) {
            continue;
        }

        let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
        changed |= update.changed;

        let Some(summary) = update.summary else {
            continue;
        };

        seen_ids.insert(game_id.to_string());
        summaries.insert(game_id.to_string(), summary.clone());
        games.push(game_from_summary(&summary));
    }

    let before = index.games.len();
    index.games.retain(|game_id, _| seen_ids.contains(game_id));
    if index.games.len() != before {
        changed = true;
    }

    if changed {
        index.revision = index.revision.saturating_add(1);
        if let Err(err) = save_library_index(&index_path, &index).await {
            log::warn!(
                "Failed to persist library index {}: {err}",
                index_path.display()
            );
        }
    }

    Ok(LocalLibraryScan {
        game_db: GameDB::from(games),
        summaries,
        revision: index.revision,
    })
}

/// Rescans a single game root through the cached index and returns full library state.
pub async fn rescan_local_game(
    game_dir: impl AsRef<Path>,
    catalog: &HashSet<String>,
    game_id: &str,
) -> eyre::Result<LocalLibraryScan> {
    let game_path = game_dir.as_ref();
    let index_path = library_index_path(game_path);
    let mut index = load_library_index(&index_path).await;

    let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
    if update.changed {
        index.revision = index.revision.saturating_add(1);
        if let Err(err) = save_library_index(&index_path, &index).await {
            log::warn!(
                "Failed to persist library index {}: {err}",
                index_path.display()
            );
        }
    }

    Ok(scan_from_index(&index))
}

// =============================================================================
// Game file descriptions
// =============================================================================

/// Gets file descriptions for a game from the local filesystem.
pub async fn get_game_file_descriptions(
    game_id: &str,
    game_dir: impl AsRef<Path>,
) -> Result<Vec<GameFileDescription>, PeerError> {
    scan_game_descriptions(game_id, game_dir.as_ref()).await
}

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

    use lanspread_proto::Availability;

    use super::*;
    use crate::{context::OperationKind, test_support::TempDir};

    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_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex {
        LibraryIndex {
            revision,
            games: HashMap::from([(
                id.to_string(),
                GameIndexEntry {
                    summary: GameSummary {
                        id: id.to_string(),
                        name: id.to_string(),
                        size: manifest_hash,
                        downloaded: true,
                        installed: false,
                        eti_version: Some("20250101".to_string()),
                        manifest_hash,
                        availability: Availability::Ready,
                    },
                    fingerprint: GameFingerprint {
                        eti_files: Vec::new(),
                        version_mtime: Some(manifest_hash),
                        local_dir_present: false,
                    },
                },
            )]),
        }
    }

    #[tokio::test]
    async fn save_library_index_is_atomic_on_replace() {
        let temp = TempDir::new("lanspread-local-games");
        let index_path = library_index_path(temp.path());
        let tmp_path = library_index_tmp_path(&index_path);

        let first = test_library_index(1, "game-a", 11);
        save_library_index(&index_path, &first)
            .await
            .expect("first index write should succeed");
        assert!(!tmp_path.exists());

        let second = test_library_index(2, "game-b", 22);
        save_library_index(&index_path, &second)
            .await
            .expect("replacement index write should succeed");

        assert!(!tmp_path.exists());
        let loaded = load_library_index(&index_path).await;
        assert_eq!(loaded.revision, 2);
        assert!(!loaded.games.contains_key("game-a"));
        let game_b = loaded
            .games
            .get("game-b")
            .expect("replacement index should be persisted");
        assert_eq!(game_b.summary.manifest_hash, 22);
    }

    #[tokio::test]
    async fn load_library_index_sweeps_stale_tmp() {
        let temp = TempDir::new("lanspread-local-games");
        let index_path = library_index_path(temp.path());
        let tmp_path = library_index_tmp_path(&index_path);
        let canonical = test_library_index(7, "game", 77);
        let data = serde_json::to_vec_pretty(&canonical).expect("index should serialize");

        write_file(&index_path, &data);
        write_file(&tmp_path, b"{ not json");

        let loaded = load_library_index(&index_path).await;

        assert_eq!(loaded.revision, 7);
        assert!(loaded.games.contains_key("game"));
        assert!(!tmp_path.exists());
    }

    #[tokio::test]
    async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
        let temp = TempDir::new("lanspread-local-games");
        let catalog = HashSet::from([
            "ready".to_string(),
            "local-only".to_string(),
            "eti-only".to_string(),
        ]);

        write_file(&temp.path().join("ready").join("version.ini"), b"20250101");
        std::fs::create_dir_all(temp.path().join("local-only").join("local"))
            .expect("local dir should be created");
        write_file(
            &temp.path().join("eti-only").join("eti-only.eti"),
            b"archive",
        );
        write_file(
            &temp.path().join("non-catalog").join("version.ini"),
            b"20250101",
        );

        let scan = scan_local_library(temp.path(), &catalog)
            .await
            .expect("scan should succeed");

        let ready = scan
            .summaries
            .get("ready")
            .expect("catalog game with sentinel should be indexed");
        assert!(ready.downloaded);
        assert!(!ready.installed);
        assert_eq!(ready.eti_version.as_deref(), Some("20250101"));
        assert_eq!(ready.availability, Availability::Ready);

        let local_only = scan
            .summaries
            .get("local-only")
            .expect("local-only install should be indexed");
        assert!(!local_only.downloaded);
        assert!(local_only.installed);
        assert_eq!(local_only.availability, Availability::LocalOnly);

        let eti_only = scan
            .summaries
            .get("eti-only")
            .expect("eti-only root should be retained as local state");
        assert!(!eti_only.downloaded);
        assert!(!eti_only.installed);
        assert_eq!(eti_only.availability, Availability::LocalOnly);

        assert!(!scan.summaries.contains_key("non-catalog"));
    }

    #[tokio::test]
    async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
        let temp = TempDir::new("lanspread-local-games");
        let catalog = HashSet::from(["game".to_string()]);
        std::fs::create_dir_all(temp.path().join("game").join("local"))
            .expect("local install dir should be created");

        let first_scan = scan_local_library(temp.path(), &catalog)
            .await
            .expect("initial scan should succeed");
        let local_only = first_scan
            .summaries
            .get("game")
            .expect("installed-only game should be indexed");
        assert!(!local_only.downloaded);
        assert!(local_only.installed);
        assert_eq!(local_only.availability, Availability::LocalOnly);

        write_file(&temp.path().join("game").join("version.ini"), b"20250101");

        let rescan = rescan_local_game(temp.path(), &catalog, "game")
            .await
            .expect("rescan should succeed");
        let ready = rescan
            .summaries
            .get("game")
            .expect("ready game should remain indexed");
        assert!(ready.downloaded);
        assert!(ready.installed);
        assert_eq!(ready.eti_version.as_deref(), Some("20250101"));
        assert_eq!(ready.availability, Availability::Ready);
    }

    #[tokio::test]
    async fn local_download_available_gates_on_catalog_operation_and_sentinel() {
        let temp = TempDir::new("lanspread-local-games");
        let game_root = temp.path().join("game");
        write_file(&game_root.join("version.ini"), b"20250101");

        let catalog = HashSet::from(["game".to_string()]);
        let no_operations = HashMap::new();
        assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);

        let active_operations = HashMap::from([("game".to_string(), OperationKind::Downloading)]);
        assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);

        assert!(
            !local_download_available(temp.path(), "game", &no_operations, &HashSet::new()).await
        );
        assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
    }
}
