//! Game detection, game-presence, and game-reactive backgrounds.
//!
//! A single local "what game is running" detector feeds two consumers:
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
//!    roster, riding the gossip presence plane like nickname + avatar.
//!
//! This module is structured testable-seams-first: the *pure* logic lives here
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
//! cancellable poll service ([`detector`]) wires them together.

pub mod detector;
pub mod scan;
pub mod steam;
pub mod vdf;

use std::collections::{BTreeMap, BTreeSet};

/// Where a detected game came from. Encodes the trust/priority tier directly:
/// a manual override beats live Steam state, which beats a matched process. Used
/// only for prioritization and as a presentation hint — never trusted as identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameSource {
    /// The user forced a specific game (or "none") via the manual override.
    Manual,
    /// Steam's live `RunningAppID` resolved against an `appmanifest`.
    Steam,
    /// A running process matched against the user's process→name mappings.
    Process,
}

/// A game the local detector currently believes is running.
///
/// `id` is the stable, namespaced identity used as the config key for backgrounds
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
/// the human label shown locally and broadcast as presence; it is `None` only for
/// the Steam appid-without-manifest case, where the background can still switch by
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedGame {
    /// Stable namespaced identity. Config-key safe; survives renames.
    pub id: String,
    /// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
    pub name: Option<String>,
    /// Provenance / priority tier.
    pub source: GameSource,
}

impl DetectedGame {
    /// The Steam namespaced id for an appid: `steam:<appid>`.
    pub fn steam_id(app_id: u32) -> String {
        format!("steam:{app_id}")
    }

    /// The process namespaced id for an executable identity: `exe:<normalized>`.
    pub fn exe_id(exe: &str) -> String {
        format!("exe:{}", normalize_exe(exe))
    }
}

/// The user's manual override sitting above both detectors (D2). Small by design.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ManualOverride {
    /// Trust the auto-detector (default).
    #[default]
    Auto,
    /// Force "not playing anything" regardless of what is detected.
    ForceNone,
    /// Force a specific game (the user picked it from the known-games list).
    Force(DetectedGame),
}

/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
    pub game: Option<DetectedGame>,
    /// `true` when a manual override (`ForceNone`/`Force`) decided the value.
    pub immediate: bool,
}

/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
/// deterministic priority order (see [`match_processes`]); its first entry wins.
pub fn resolve(
    override_: &ManualOverride,
    steam: Option<DetectedGame>,
    processes: &[DetectedGame],
) -> Resolution {
    match override_ {
        ManualOverride::ForceNone => Resolution {
            game: None,
            immediate: true,
        },
        ManualOverride::Force(g) => Resolution {
            game: Some(g.clone()),
            immediate: true,
        },
        ManualOverride::Auto => {
            let game = steam.or_else(|| processes.first().cloned());
            Resolution {
                game,
                immediate: false,
            }
        }
    }
}

/// Samples required before a *new* game is accepted/switched to.
pub const ACCEPT_HITS: u32 = 2;
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
pub const CLEAR_MISSES: u32 = 3;

/// Debounces a stream of raw per-poll detections into a stable published value, so
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
///
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
/// (`immediate = true`) applies at once, bypassing both counters.
#[derive(Debug, Clone, Default)]
pub struct Debouncer {
    current: Option<DetectedGame>,
    pending: Option<DetectedGame>,
    pending_hits: u32,
    misses: u32,
}

impl Debouncer {
    /// The currently published, debounced value.
    pub fn current(&self) -> Option<&DetectedGame> {
        self.current.as_ref()
    }

    /// Feed one poll result. `immediate` (a manual override is active) bypasses the
    /// debounce. Returns `true` iff the published [`current`](Self::current) value
    /// changed — the signal for the service to re-announce presence / switch the
    /// background.
    pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
        if immediate {
            let changed = self.current != sample;
            self.current = sample;
            self.pending = None;
            self.pending_hits = 0;
            self.misses = 0;
            return changed;
        }
        match sample {
            Some(game) => {
                self.misses = 0;
                if self.current.as_ref() == Some(&game) {
                    // Already publishing this game; drop any half-counted switch.
                    self.pending = None;
                    self.pending_hits = 0;
                    false
                } else {
                    if self.pending.as_ref() == Some(&game) {
                        self.pending_hits += 1;
                    } else {
                        self.pending = Some(game);
                        self.pending_hits = 1;
                    }
                    if self.pending_hits >= ACCEPT_HITS {
                        self.current = self.pending.take();
                        self.pending_hits = 0;
                        true
                    } else {
                        false
                    }
                }
            }
            None => {
                // A miss never counts toward a *switch*; drop any pending candidate.
                self.pending = None;
                self.pending_hits = 0;
                if self.current.is_some() {
                    self.misses += 1;
                    if self.misses >= CLEAR_MISSES {
                        self.current = None;
                        self.misses = 0;
                        true
                    } else {
                        false
                    }
                } else {
                    false
                }
            }
        }
    }
}

/// Normalize a raw executable name/path to a stable identity for matching and ids:
/// take the final path component (handling both `/` and `\\` separators) and
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
/// hypothetical `minecraft`), trims surrounding whitespace.
pub fn normalize_exe(raw: &str) -> String {
    raw.rsplit(['/', '\\'])
        .next()
        .unwrap_or(raw)
        .trim()
        .to_lowercase()
}

/// Launcher/helper executables that must NEVER be reported as a game even if a
/// mapping names them — defense against a mis-entered mapping turning the launcher
/// itself into "the game". Normalized (lowercase basename) for comparison.
const BUILTIN_DENYLIST: &[&str] = &[
    "steam",
    "steam.exe",
    "steamwebhelper",
    "steamwebhelper.exe",
    "steamerrorreporter",
    "gameoverlayui",
    "reaper",
    "lutris",
    "heroic",
    "heroic.exe",
    "legendary",
    "gogdl",
    "wine",
    "wine64",
    "wineserver",
    "wine-preloader",
    "proton",
    "pressure-vessel-wrap",
    "explorer.exe",
    "services.exe",
    "svchost.exe",
];

/// The built-in launcher/helper denylist as a set, for membership checks.
pub fn builtin_denylist() -> BTreeSet<&'static str> {
    BUILTIN_DENYLIST.iter().copied().collect()
}

/// Match the currently-running executables against the user's explicit
/// process→display-name mappings, returning detected games in **deterministic
/// priority order** (sorted by stable id) with duplicates removed.
///
/// Conservative by construction (§3): only exact normalized-basename matches to a
/// user mapping count — we never guess that an arbitrary long-running process is a
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
/// helper can't be promoted to "the game".
///
/// `user_map` keys are matched against the normalized basename of each running
/// entry; the key itself is normalized too, so the caller may store either
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
pub fn match_processes(
    running: &[String],
    user_map: &BTreeMap<String, String>,
    denylist: &BTreeSet<&str>,
) -> Vec<DetectedGame> {
    // Normalize the user map once so lookups are basename/case-insensitive.
    let normalized_map: BTreeMap<String, &String> = user_map
        .iter()
        .map(|(k, v)| (normalize_exe(k), v))
        .collect();

    let mut seen: BTreeSet<String> = BTreeSet::new();
    let mut out: Vec<DetectedGame> = Vec::new();
    for raw in running {
        let norm = normalize_exe(raw);
        if norm.is_empty() || denylist.contains(norm.as_str()) {
            continue;
        }
        if let Some(name) = normalized_map.get(&norm) {
            let id = format!("exe:{norm}");
            if seen.insert(id.clone()) {
                out.push(DetectedGame {
                    id,
                    name: Some((*name).clone()),
                    source: GameSource::Process,
                });
            }
        }
    }
    // Deterministic priority: stable order independent of process-scan order.
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn steam_game(app_id: u32, name: &str) -> DetectedGame {
        DetectedGame {
            id: DetectedGame::steam_id(app_id),
            name: Some(name.to_string()),
            source: GameSource::Steam,
        }
    }

    // --- ids / normalization ----------------------------------------------

    #[test]
    fn stable_ids_are_namespaced() {
        assert_eq!(DetectedGame::steam_id(730), "steam:730");
        assert_eq!(
            DetectedGame::exe_id("/usr/games/hl2_linux"),
            "exe:hl2_linux"
        );
        assert_eq!(
            DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
            "exe:minecraft.exe"
        );
    }

    #[test]
    fn normalize_handles_both_separators_and_case() {
        assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
        assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
        assert_eq!(normalize_exe("  spaced.bin  "), "spaced.bin");
        assert_eq!(normalize_exe("bare"), "bare");
    }

    // --- resolve priority --------------------------------------------------

    #[test]
    fn resolve_prefers_steam_over_process_in_auto() {
        let steam = steam_game(730, "CS2");
        let procs = vec![DetectedGame {
            id: "exe:foo".into(),
            name: Some("Foo".into()),
            source: GameSource::Process,
        }];
        let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
        assert_eq!(r.game, Some(steam));
        assert!(!r.immediate);
    }

    #[test]
    fn resolve_falls_back_to_first_process_then_none() {
        let procs = vec![
            DetectedGame {
                id: "exe:a".into(),
                name: Some("A".into()),
                source: GameSource::Process,
            },
            DetectedGame {
                id: "exe:b".into(),
                name: Some("B".into()),
                source: GameSource::Process,
            },
        ];
        let r = resolve(&ManualOverride::Auto, None, &procs);
        assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
        let none = resolve(&ManualOverride::Auto, None, &[]);
        assert_eq!(none.game, None);
        assert!(!none.immediate);
    }

    #[test]
    fn resolve_manual_override_wins_and_is_immediate() {
        let steam = steam_game(730, "CS2");
        // ForceNone overrides a live Steam detection, immediately.
        let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
        assert_eq!(r.game, None);
        assert!(r.immediate);
        // Force(x) overrides too.
        let forced = steam_game(220, "HL2");
        let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
        assert_eq!(r.game, Some(forced));
        assert!(r.immediate);
    }

    // --- debounce ----------------------------------------------------------

    #[test]
    fn debounce_requires_two_hits_to_switch() {
        let mut d = Debouncer::default();
        let g = steam_game(730, "CS2");
        // First sighting: not yet published.
        assert!(!d.observe(Some(g.clone()), false));
        assert_eq!(d.current(), None);
        // Second consecutive sighting: now published.
        assert!(d.observe(Some(g.clone()), false));
        assert_eq!(d.current(), Some(&g));
        // Steady state: same game, no further change events.
        assert!(!d.observe(Some(g.clone()), false));
    }

    #[test]
    fn debounce_requires_three_misses_to_clear() {
        let mut d = Debouncer::default();
        let g = steam_game(730, "CS2");
        d.observe(Some(g.clone()), false);
        d.observe(Some(g.clone()), false);
        assert_eq!(d.current(), Some(&g));
        // Two misses: still shown (absorbs a transient blip).
        assert!(!d.observe(None, false));
        assert!(!d.observe(None, false));
        assert_eq!(d.current(), Some(&g));
        // Third miss: cleared.
        assert!(d.observe(None, false));
        assert_eq!(d.current(), None);
    }

    #[test]
    fn debounce_blip_during_clear_resets_miss_count() {
        let mut d = Debouncer::default();
        let g = steam_game(730, "CS2");
        d.observe(Some(g.clone()), false);
        d.observe(Some(g.clone()), false);
        // Miss, miss, then the game reappears: miss count resets, stays published.
        d.observe(None, false);
        d.observe(None, false);
        assert!(!d.observe(Some(g.clone()), false));
        assert_eq!(d.current(), Some(&g));
        // It now takes a fresh run of three misses to clear.
        d.observe(None, false);
        d.observe(None, false);
        assert!(d.observe(None, false));
        assert_eq!(d.current(), None);
    }

    #[test]
    fn debounce_immediate_bypasses_counters() {
        let mut d = Debouncer::default();
        let g = steam_game(730, "CS2");
        // A manual override publishes on the first sample.
        assert!(d.observe(Some(g.clone()), true));
        assert_eq!(d.current(), Some(&g));
        // ForceNone clears immediately.
        assert!(d.observe(None, true));
        assert_eq!(d.current(), None);
        // Re-issuing the same immediate value is not a change.
        d.observe(Some(g.clone()), true);
        assert!(!d.observe(Some(g.clone()), true));
    }

    #[test]
    fn debounce_switching_games_needs_two_hits_of_the_new_one() {
        let mut d = Debouncer::default();
        let a = steam_game(1, "A");
        let b = steam_game(2, "B");
        d.observe(Some(a.clone()), false);
        d.observe(Some(a.clone()), false);
        assert_eq!(d.current(), Some(&a));
        // One sample of B does not switch.
        assert!(!d.observe(Some(b.clone()), false));
        assert_eq!(d.current(), Some(&a));
        // Second consecutive B switches.
        assert!(d.observe(Some(b.clone()), false));
        assert_eq!(d.current(), Some(&b));
    }

    // --- process matching --------------------------------------------------

    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
        pairs
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect()
    }

    #[test]
    fn match_processes_matches_only_explicit_mappings() {
        let user = map(&[("hl2_linux", "Half-Life 2")]);
        let deny = builtin_denylist();
        let running = vec![
            "/usr/bin/firefox".to_string(),
            "/games/Half-Life 2/hl2_linux".to_string(),
            "/usr/bin/htop".to_string(),
        ];
        let got = match_processes(&running, &user, &deny);
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].id, "exe:hl2_linux");
        assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
        assert_eq!(got[0].source, GameSource::Process);
    }

    #[test]
    fn match_processes_rejects_denylisted_even_if_mapped() {
        // A mis-entered mapping naming the Steam client must not win.
        let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
        let deny = builtin_denylist();
        let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
        let got = match_processes(&running, &user, &deny);
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].id, "exe:mygame");
    }

    #[test]
    fn match_processes_is_deterministic_and_deduped() {
        let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
        let deny = builtin_denylist();
        // Same game twice (two processes) + reverse discovery order.
        let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
        let got = match_processes(&running, &user, &deny);
        // Deduped to two, sorted by id (alpha before zed) regardless of scan order.
        assert_eq!(
            got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(),
            vec!["exe:alpha", "exe:zed"]
        );
    }

    #[test]
    fn match_processes_ignores_unmapped_and_case_folds() {
        let user = map(&[("Game.x86_64", "The Game")]);
        let deny = builtin_denylist();
        let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
        let got = match_processes(&running, &user, &deny);
        assert_eq!(got.len(), 1);
        assert_eq!(got[0].name.as_deref(), Some("The Game"));
    }
}
