#![allow(clippy::missing_errors_doc)]
#![allow(clippy::doc_markdown)]

use std::{collections::HashMap, fmt, path::Path};

use serde::{Deserialize, Serialize};

/// Read version from version.ini file
/// # Errors
/// Returns error if file cannot be read or parsed
pub fn read_version_from_ini(game_dir: &Path) -> eyre::Result<Option<String>> {
    let version_file = game_dir.join("version.ini");
    if !version_file.exists() {
        return Ok(None);
    }

    let content = std::fs::read_to_string(&version_file)?;
    let version = content.trim().to_string();

    // Validate format (YYYYMMDD)
    if version.len() == 8 && version.chars().all(|c| c.is_ascii_digit()) {
        Ok(Some(version))
    } else {
        tracing::warn!(
            "Invalid version format in {}: {}",
            version_file.display(),
            version
        );
        Ok(None)
    }
}

#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum Availability {
    Ready,
    #[default]
    LocalOnly,
}

impl Availability {
    #[must_use]
    pub fn from_downloaded(downloaded: bool) -> Self {
        if downloaded {
            Self::Ready
        } else {
            Self::LocalOnly
        }
    }
}

/// A game
#[derive(Clone, Serialize, Deserialize)]
pub struct Game {
    /// example: aoe2
    pub id: String,
    /// example: Age of Empires 2
    pub name: String,
    /// example: Dieses Paket enthält die original AoE 2 Version,...
    pub description: String,
    /// example: 1999
    pub release_year: String,
    /// Microsoft
    pub publisher: String,
    /// example: 8
    pub max_players: u32,
    /// example: 3.5
    pub version: String,
    /// example: Echtzeit-Strategie
    pub genre: String,
    /// size in bytes: example: 3455063152
    pub size: u64,
    /// indicates that the ETI bundle exists locally
    #[serde(default)]
    pub downloaded: bool,
    /// only relevant for client (yeah... I know)
    #[serde(default)]
    pub installed: bool,
    /// Backend-reported availability state for this game's local or peer summary.
    #[serde(default)]
    pub availability: Availability,
    /// ETI game version from version.ini (YYYYMMDD format) (server)
    pub eti_game_version: Option<String>,
    /// Local game version from version.ini (YYYYMMDD format)
    pub local_version: Option<String>,
    /// Number of peers that have this game available
    pub peer_count: u32,
}

impl Game {
    /// Sets sentinel-derived download state and matching availability.
    pub fn set_downloaded(&mut self, downloaded: bool) {
        self.downloaded = downloaded;
        self.availability = Availability::from_downloaded(downloaded);
    }

    #[must_use]
    pub fn normalized_availability(&self) -> Availability {
        if self.downloaded {
            Availability::Ready
        } else {
            Availability::LocalOnly
        }
    }
}

impl fmt::Debug for Game {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{}: {}  ({} MB)",
            self.id,
            self.name,
            self.size / 1024 / 1024,
        )
    }
}

impl fmt::Display for Game {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.name)
    }
}

impl PartialEq for Game {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id
    }
}

impl Eq for Game {}

impl PartialOrd for Game {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Game {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.name.cmp(&other.name)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameDB {
    pub games: HashMap<String, Game>,
}

impl GameDB {
    #[must_use]
    pub fn empty() -> Self {
        GameDB {
            games: HashMap::new(),
        }
    }

    #[must_use]
    pub fn from(games: Vec<Game>) -> Self {
        let mut db = GameDB::empty();
        for game in games {
            db.games.insert(game.id.clone(), game);
        }
        db
    }

    #[must_use]
    pub fn get_game_by_id<S>(&self, id: S) -> Option<&Game>
    where
        S: AsRef<str>,
    {
        self.games.get(id.as_ref())
    }

    #[must_use]
    pub fn get_mut_game_by_id<S>(&mut self, id: S) -> Option<&mut Game>
    where
        S: AsRef<str>,
    {
        self.games.get_mut(id.as_ref())
    }

    #[must_use]
    pub fn get_game_by_name(&self, name: &str) -> Option<&Game> {
        self.games.values().find(|game| game.name == name)
    }

    #[must_use]
    pub fn all_games(&self) -> Vec<&Game> {
        let mut games: Vec<_> = self.games.values().collect();
        games.sort_by(|a, b| a.name.cmp(&b.name));
        games
    }
}

impl Default for GameDB {
    fn default() -> Self {
        Self::empty()
    }
}

#[derive(Clone, Serialize, Deserialize)]
pub struct GameFileDescription {
    pub game_id: String,
    pub relative_path: String,
    pub is_dir: bool,
    pub size: u64,
}

impl GameFileDescription {
    #[must_use]
    pub fn is_version_ini(&self) -> bool {
        let expected = format!("{}/version.ini", self.game_id);
        self.relative_path.replace('\\', "/") == expected
    }

    #[must_use]
    pub fn file_size(&self) -> u64 {
        if self.is_dir { 0 } else { self.size }
    }
}

impl fmt::Debug for GameFileDescription {
    #[allow(clippy::cast_precision_loss)]
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{}: [{}] path:{} size:{}",
            self.game_id,
            if self.is_dir { 'D' } else { 'F' },
            self.relative_path,
            self.size,
        )
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::{Availability, Game, GameFileDescription};

    fn game_fixture() -> Game {
        Game {
            id: "aoe2".to_string(),
            name: "Age of Empires II".to_string(),
            description: "desc".to_string(),
            release_year: "1999".to_string(),
            publisher: "Microsoft".to_string(),
            max_players: 8,
            version: "1.0".to_string(),
            genre: "RTS".to_string(),
            size: 123_456,
            downloaded: false,
            installed: false,
            availability: Availability::LocalOnly,
            eti_game_version: None,
            local_version: None,
            peer_count: 0,
        }
    }

    #[test]
    fn missing_client_state_defaults_to_false_and_local_only() {
        let raw = json!({
            "id": "aoe2",
            "name": "Age of Empires II",
            "description": "desc",
            "release_year": "1999",
            "publisher": "Microsoft",
            "max_players": 8,
            "version": "1.0",
            "genre": "RTS",
            "size": 123_456,
            "thumbnail": null,
            "downloaded": true,
            "eti_game_version": "20240101",
            "local_version": null,
            "peer_count": 2
        })
        .to_string();

        let game: Game = serde_json::from_str(&raw).expect("game should deserialize");
        assert!(
            !game.installed,
            "missing installed flag should default to false"
        );
        assert_eq!(game.availability, Availability::LocalOnly);
    }

    #[test]
    fn download_state_helpers_keep_ready_in_lockstep() {
        assert_eq!(Availability::from_downloaded(true), Availability::Ready);
        assert_eq!(
            Availability::from_downloaded(false),
            Availability::LocalOnly
        );

        let mut game = game_fixture();
        game.set_downloaded(true);
        assert!(game.downloaded);
        assert_eq!(game.availability, Availability::Ready);

        game.set_downloaded(false);
        assert!(!game.downloaded);
        assert_eq!(game.availability, Availability::LocalOnly);

        game.availability = Availability::Ready;
        assert_eq!(game.normalized_availability(), Availability::LocalOnly);

        game.downloaded = true;
        assert_eq!(game.normalized_availability(), Availability::Ready);
    }

    #[test]
    fn version_ini_predicate_matches_only_game_root_sentinel() {
        let root = GameFileDescription {
            game_id: "aoe2".to_string(),
            relative_path: "aoe2/version.ini".to_string(),
            is_dir: false,
            size: 8,
        };
        assert!(root.is_version_ini());

        let nested = GameFileDescription {
            game_id: "aoe2".to_string(),
            relative_path: "aoe2/local/version.ini".to_string(),
            is_dir: false,
            size: 8,
        };
        assert!(!nested.is_version_ini());

        let other_game = GameFileDescription {
            game_id: "aoe2".to_string(),
            relative_path: "other/version.ini".to_string(),
            is_dir: false,
            size: 8,
        };
        assert!(!other_game.is_version_ini());
    }

    #[test]
    fn version_ini_predicate_accepts_windows_separators() {
        let root = GameFileDescription {
            game_id: "aoe2".to_string(),
            relative_path: r"aoe2\version.ini".to_string(),
            is_dir: false,
            size: 8,
        };
        assert!(root.is_version_ini());
    }
}
