//! Steam detection adapter: the primary signal (D1). Reads Steam's live
//! `RunningAppID` and resolves it to a display name via the plain-text
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
//!
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
//! poll does not rescan every library each tick (Codex hardening).

use super::vdf::{self, Value};
use super::{DetectedGame, GameSource};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// Max bytes read from any single Steam state file. These are small text files
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
/// slurped into memory before the parser's own depth guard kicks in.
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
/// prevents a corrupt registry length from driving an enormous allocation.
#[cfg(any(windows, test))]
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;

#[cfg(any(windows, test))]
fn validate_reg_len(len: u32) -> Option<usize> {
    (len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2)
}

#[cfg(any(windows, test))]
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
    let units = validate_reg_len(returned_bytes)?;
    if units > buf.len() {
        return None;
    }
    buf.truncate(units);
    while buf.last() == Some(&0) {
        buf.pop();
    }
    Some(String::from_utf16_lossy(&buf))
}

/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
/// client's emulated-registry text file). Returns the appid only when present and
/// nonzero — `0`/absent is the "no game" state. Pure.
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
    let root = vdf::parse(registry_vdf).ok()?;
    let raw = root
        .get_path(&[
            "Registry",
            "HKCU",
            "Software",
            "Valve",
            "Steam",
            "RunningAppID",
        ])
        .and_then(Value::as_str)?;
    let id: u32 = raw.trim().parse().ok()?;
    (id != 0).then_some(id)
}

/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
/// already decoded by the VDF parser), including ones on offline drives — the
/// caller checks existence.
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
    let Ok(root) = vdf::parse(libraryfolders_vdf) else {
        return Vec::new();
    };
    // The root may or may not wrap entries in a "libraryfolders" object.
    let container = root.get("libraryfolders").unwrap_or(&root);
    let mut out = Vec::new();
    for (key, val) in container.entries() {
        // Only numeric-keyed entries are library folders.
        if key.parse::<u32>().is_err() {
            continue;
        }
        let path = match val {
            Value::Str(s) => Some(s.as_str()),
            Value::Obj(_) => val.get("path").and_then(Value::as_str),
        };
        if let Some(p) = path
            && !p.is_empty()
        {
            out.push(PathBuf::from(p));
        }
    }
    out
}

/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
    let root = vdf::parse(appmanifest_acf).ok()?;
    root.get_path(&["AppState", "name"])
        .and_then(Value::as_str)
        .map(|s| s.to_string())
        .filter(|s| !s.is_empty())
}

/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
fn read_capped(path: &Path) -> Option<String> {
    use std::io::Read;
    let file = std::fs::File::open(path).ok()?;
    let mut buf = Vec::new();
    file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
    Some(String::from_utf8_lossy(&buf).into_owned())
}

fn mtime_of(path: &Path) -> Option<SystemTime> {
    std::fs::metadata(path).ok()?.modified().ok()
}

/// A library list cached against its source file's mtime.
#[derive(Default)]
struct CachedLibraries {
    source: Option<PathBuf>,
    mtime: Option<SystemTime>,
    paths: Vec<PathBuf>,
}

/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
/// when the manifest exists but carries no usable name, or wasn't found.
struct CachedManifest {
    mtime: Option<SystemTime>,
    name: Option<String>,
}

/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
/// service runs it off the async worker.
pub struct SteamProbe {
    roots: Vec<PathBuf>,
    libraries: CachedLibraries,
    manifests: HashMap<u32, CachedManifest>,
}

impl Default for SteamProbe {
    fn default() -> Self {
        Self::new()
    }
}

impl SteamProbe {
    pub fn new() -> Self {
        Self {
            roots: discover_roots(),
            libraries: CachedLibraries::default(),
            manifests: HashMap::new(),
        }
    }

    /// One detection pass: read the live `RunningAppID`, and if a game is running,
    /// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
    /// with `name: None` when the appid is known but no manifest name is available
    /// — the background can still switch by id, but presence must not invent a name.
    pub fn detect(&mut self) -> Option<DetectedGame> {
        let app_id = self.running_app_id()?;
        let name = self.app_name(app_id);
        Some(DetectedGame {
            id: DetectedGame::steam_id(app_id),
            name,
            source: GameSource::Steam,
        })
    }

    /// The live RunningAppID (nonzero), or `None`.
    ///
    /// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
    /// live, so we read it. On **Linux** the client's `registry.vdf` is only
    /// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
    /// signal is the running game process's `SteamAppId` environment variable
    /// (`/proc/<pid>/environ`, readable for our own processes; the same approach
    /// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
    /// (macOS) only has the `registry.vdf` fallback for now.
    fn running_app_id(&self) -> Option<u32> {
        #[cfg(windows)]
        {
            win::running_app_id()
        }
        #[cfg(target_os = "linux")]
        {
            running_app_id_from_environ().or_else(registry_running_app_id)
        }
        #[cfg(not(any(windows, target_os = "linux")))]
        {
            registry_running_app_id()
        }
    }

    /// Resolve (and cache) the display name for an appid by locating its
    /// `appmanifest_<appid>.acf` across the known libraries.
    fn app_name(&mut self, app_id: u32) -> Option<String> {
        let manifest = self.find_manifest(app_id)?;
        let mtime = mtime_of(&manifest);
        if let Some(cached) = self.manifests.get(&app_id)
            && cached.mtime == mtime
        {
            return cached.name.clone();
        }
        let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
        self.manifests.insert(
            app_id,
            CachedManifest {
                mtime,
                name: name.clone(),
            },
        );
        name
    }

    /// The path to an appid's manifest, if it exists in any library.
    fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
        let filename = format!("appmanifest_{app_id}.acf");
        for lib in self.library_paths() {
            let candidate = lib.join("steamapps").join(&filename);
            if candidate.exists() {
                return Some(candidate);
            }
        }
        None
    }

    /// All Steam library folder paths, cached and refreshed only when the source
    /// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
    fn library_paths(&mut self) -> Vec<PathBuf> {
        // Locate the libraryfolders.vdf to watch (first existing across roots).
        let source = self
            .roots
            .iter()
            .map(|r| r.join("steamapps").join("libraryfolders.vdf"))
            .find(|p| p.exists());

        let mtime = source.as_deref().and_then(mtime_of);
        if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
            return self.libraries.paths.clone();
        }

        let mut paths = Vec::new();
        if let Some(ref src) = source
            && let Some(contents) = read_capped(src)
        {
            paths = parse_library_paths(&contents);
        }
        // Always include the roots themselves: the install dir is an implicit
        // library even if libraryfolders.vdf is missing or lists only extras.
        for root in &self.roots {
            if !paths.contains(root) {
                paths.push(root.clone());
            }
        }
        self.libraries = CachedLibraries {
            source,
            mtime,
            paths: paths.clone(),
        };
        paths
    }
}

/// Candidate Steam install roots that actually exist on this machine (each is a
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
/// layouts on Linux; on Windows the install path comes from the registry.
fn discover_roots() -> Vec<PathBuf> {
    let mut roots = Vec::new();

    #[cfg(windows)]
    {
        if let Some(p) = win::install_path() {
            roots.push(p);
        }
    }

    #[cfg(not(windows))]
    {
        if let Some(home) = dirs::home_dir() {
            for rel in [
                ".steam/steam",
                ".steam/root",
                ".local/share/Steam",
                ".var/app/com.valvesoftware.Steam/.local/share/Steam",
                "snap/steam/common/.local/share/Steam",
            ] {
                roots.push(home.join(rel));
            }
        }
    }

    // Keep only roots that exist and look like a Steam install.
    roots.retain(|p| p.join("steamapps").is_dir());
    roots.sort();
    roots.dedup();
    roots
}

/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
#[cfg(not(windows))]
fn registry_vdf_candidates() -> Vec<PathBuf> {
    let mut out = Vec::new();
    if let Some(home) = dirs::home_dir() {
        out.push(home.join(".steam/registry.vdf"));
        out.push(home.join(".steam/steam/registry.vdf"));
        out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
        out.push(home.join("snap/steam/common/.steam/registry.vdf"));
    }
    out
}

/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
#[cfg(not(windows))]
fn registry_running_app_id() -> Option<u32> {
    for path in registry_vdf_candidates() {
        if let Some(contents) = read_capped(&path)
            && let Some(id) = parse_running_app_id(&contents)
        {
            return Some(id);
        }
    }
    None
}

/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
/// game process. Returns the appid only when present and nonzero. Pure +
/// unit-tested; the `/proc` iteration is the thin edge in
/// [`running_app_id_from_environ`].
#[cfg(target_os = "linux")]
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
    for kv in environ.split(|&b| b == 0) {
        if let Some(val) = kv.strip_prefix(b"SteamAppId=")
            && let Ok(s) = std::str::from_utf8(val)
            && let Ok(id) = s.trim().parse::<u32>()
            && id != 0
        {
            return Some(id);
        }
    }
    None
}

/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
/// readable only for our own processes — exactly the ones a Steam game we launched
/// runs as — and we skip the rest. The live signal that replaces the stale
/// on-disk `registry.vdf` on Linux.
#[cfg(target_os = "linux")]
fn running_app_id_from_environ() -> Option<u32> {
    let entries = std::fs::read_dir("/proc").ok()?;
    for entry in entries.flatten() {
        let name = entry.file_name();
        let Some(name) = name.to_str() else { continue };
        if !name.bytes().all(|b| b.is_ascii_digit()) {
            continue;
        }
        // Cap the read: an environ is small; this bounds a pathological case.
        if let Some(environ) = read_capped(&entry.path().join("environ"))
            && let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
        {
            return Some(id);
        }
    }
    None
}

#[cfg(windows)]
mod win {
    //! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
    //! crate. Steam stores both the live `RunningAppID` and its install path under
    //! `HKCU\Software\Valve\Steam`.
    use super::{decode_reg_sz, validate_reg_len};
    use std::path::PathBuf;
    use windows_sys::Win32::Foundation::ERROR_SUCCESS;
    use windows_sys::Win32::System::Registry::{
        HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
        RegQueryValueExW,
    };

    /// UTF-16, NUL-terminated, for a Win32 wide-string argument.
    fn wide(s: &str) -> Vec<u16> {
        s.encode_utf16().chain(std::iter::once(0)).collect()
    }

    /// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
    fn open_steam_key() -> Option<HKEY> {
        let subkey = wide("Software\\Valve\\Steam");
        let mut hkey: HKEY = std::ptr::null_mut();
        // SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
        let rc =
            unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
        (rc == ERROR_SUCCESS).then_some(hkey)
    }

    /// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
    pub fn running_app_id() -> Option<u32> {
        let hkey = open_steam_key()?;
        let name = wide("RunningAppID");
        let mut kind: u32 = 0;
        let mut data: u32 = 0;
        let mut len = std::mem::size_of::<u32>() as u32;
        // SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
        let rc = unsafe {
            RegQueryValueExW(
                hkey,
                name.as_ptr(),
                std::ptr::null(),
                &mut kind,
                &mut data as *mut u32 as *mut u8,
                &mut len,
            )
        };
        // SAFETY: handle came from RegOpenKeyExW above.
        unsafe { RegCloseKey(hkey) };
        if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
            Some(data)
        } else {
            None
        }
    }

    /// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
    pub fn install_path() -> Option<PathBuf> {
        let hkey = open_steam_key()?;
        let name = wide("SteamPath");
        let mut kind: u32 = 0;
        let mut len: u32 = 0;
        // First query the size.
        // SAFETY: null data ptr with a zeroed len asks for the required size.
        let rc = unsafe {
            RegQueryValueExW(
                hkey,
                name.as_ptr(),
                std::ptr::null(),
                &mut kind,
                std::ptr::null_mut(),
                &mut len,
            )
        };
        if rc != ERROR_SUCCESS || kind != REG_SZ {
            // SAFETY: valid handle.
            unsafe { RegCloseKey(hkey) };
            return None;
        }
        let Some(units) = validate_reg_len(len) else {
            // SAFETY: valid handle.
            unsafe { RegCloseKey(hkey) };
            return None;
        };
        let mut buf = vec![0u16; units];
        let mut len2 = len;
        // SAFETY: buffer sized to the queried byte length.
        let rc = unsafe {
            RegQueryValueExW(
                hkey,
                name.as_ptr(),
                std::ptr::null(),
                &mut kind,
                buf.as_mut_ptr() as *mut u8,
                &mut len2,
            )
        };
        // SAFETY: valid handle.
        unsafe { RegCloseKey(hkey) };
        if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
            return None;
        }
        Some(PathBuf::from(decode_reg_sz(buf, len2)?))
    }
}

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

    #[test]
    fn registry_string_lengths_are_bounded_and_trimmed() {
        assert_eq!(
            validate_reg_len(5),
            None,
            "odd byte lengths are invalid UTF-16"
        );
        assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
        assert_eq!(validate_reg_len(8), Some(4));

        let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
        let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
        assert_eq!(
            decode_reg_sz(raw, returned_bytes).as_deref(),
            Some("C:\\Steam")
        );
    }

    #[test]
    fn running_app_id_reads_nonzero_and_rejects_zero() {
        let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
            "RunningAppID" "440"
        } } } } }"#;
        assert_eq!(parse_running_app_id(running), Some(440));
        let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
            "RunningAppID" "0"
        } } } } }"#;
        assert_eq!(parse_running_app_id(idle), None);
        // Missing key / garbage → None, no panic.
        assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
        assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
    }

    #[test]
    fn library_paths_handles_current_and_legacy_shapes() {
        let current = r#""libraryfolders" {
            "0" { "path" "/home/eric/.local/share/Steam" "label" "" }
            "1" { "path" "/mnt/games/SteamLibrary" }
            "contentstatsid" "12345"
        }"#;
        let got = parse_library_paths(current);
        assert_eq!(
            got,
            vec![
                PathBuf::from("/home/eric/.local/share/Steam"),
                PathBuf::from("/mnt/games/SteamLibrary"),
            ]
        );

        // Legacy shape: numeric keys map straight to path strings.
        let legacy = r#""LibraryFolders" {
            "TimeNextStatsReport" "9999"
            "ContentStatsID" "42"
            "1" "/mnt/old/SteamLibrary"
        }"#;
        let got = parse_library_paths(legacy);
        assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
    }

    #[test]
    fn library_paths_empty_on_garbage() {
        assert!(parse_library_paths("totally broken {{{").is_empty());
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn steam_app_id_parsed_from_environ_blob() {
        // A realistic NUL-separated environ with SteamAppId among other vars.
        let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
        assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
        // Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
        assert_eq!(
            parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"),
            None
        );
        // Absent → None (a non-Steam process).
        assert_eq!(
            parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"),
            None
        );
        // Not fooled by a different var that merely contains the substring.
        assert_eq!(
            parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"),
            None
        );
        // Garbage value → None, no panic.
        assert_eq!(
            parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"),
            None
        );
    }

    #[test]
    fn app_name_extracts_and_filters_empty() {
        let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
        assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
        // Empty name → None (don't broadcast a blank).
        let blank = r#""AppState" { "appid" "440" "name" "" }"#;
        assert_eq!(parse_app_name(blank), None);
        // Missing name → None.
        assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
    }
}
