//! Parsing `/proc/<pid>/maps` and finding the FIFA 17 process.

use std::fs;
use std::io;

#[derive(Debug, Clone)]
pub struct Region {
    pub start: u64,
    pub end: u64,
    /// The raw four permission characters, e.g. `rwxp` or `r--s`.
    pub perms: String,
    /// File offset this mapping starts at, meaningless for anonymous regions.
    pub offset: u64,
    /// `None` for anonymous mappings.
    pub path: Option<String>,
}

impl Region {
    pub fn size(&self) -> u64 {
        self.end - self.start
    }
    pub fn readable(&self) -> bool {
        self.perms.as_bytes().first() == Some(&b'r')
    }
    pub fn writable(&self) -> bool {
        self.perms.as_bytes().get(1) == Some(&b'w')
    }
    pub fn executable(&self) -> bool {
        self.perms.as_bytes().get(2) == Some(&b'x')
    }
    pub fn private(&self) -> bool {
        self.perms.as_bytes().get(3) == Some(&b'p')
    }
    pub fn anonymous(&self) -> bool {
        self.path.is_none()
    }
    /// Pseudo-files the kernel exposes. Reading `[vvar]` through
    /// `/proc/pid/mem` fails, and `[vsyscall]` is not interesting here.
    pub fn pseudo(&self) -> bool {
        matches!(self.path.as_deref(), Some(p) if p.starts_with('['))
    }
}

pub fn read_maps(pid: i32) -> io::Result<Vec<Region>> {
    let text = fs::read_to_string(format!("/proc/{pid}/maps")).map_err(|e| {
        let hint = if fs::metadata(format!("/proc/{pid}")).is_err() {
            format!("no process with pid {pid}")
        } else {
            format!("pid {pid} exists but its maps are unreadable (different user?)")
        };
        io::Error::new(e.kind(), format!("reading /proc/{pid}/maps: {hint}"))
    })?;
    Ok(text.lines().filter_map(parse_line).collect())
}

/// The target's `comm`, so output can name what was actually inspected rather
/// than assuming an explicit `--pid` pointed at the game.
pub fn read_comm(pid: i32) -> String {
    fs::read_to_string(format!("/proc/{pid}/comm"))
        .map(|s| s.trim().to_string())
        .unwrap_or_else(|_| "?".to_string())
}

/// Pull the next whitespace-delimited field starting at `cursor`, advancing it.
fn next_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> {
    let bytes = line.as_bytes();
    while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() {
        *cursor += 1;
    }
    let start = *cursor;
    while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
        *cursor += 1;
    }
    if start == *cursor {
        None
    } else {
        Some(&line[start..*cursor])
    }
}

fn parse_line(line: &str) -> Option<Region> {
    // Format: `start-end perms offset dev inode  path`
    //
    // The path may contain spaces (`/mnt/games/FIFA 17/FIFA17.exe`) and may
    // carry a ` (deleted)` suffix, so we consume exactly five leading fields by
    // position and take the untouched remainder as the path.
    //
    // Doing this with `line.find(inode)` to locate the split point is a trap:
    // the inode of an anonymous mapping is "0", and `find("0")` happily matches
    // a zero digit inside the address range at the very start of the line. That
    // silently turns half the address into a path. Hence the explicit cursor.
    let mut cursor = 0usize;
    let range = next_field(line, &mut cursor)?;
    let perms = next_field(line, &mut cursor)?;
    let offset = next_field(line, &mut cursor)?;
    let _dev = next_field(line, &mut cursor)?;
    let _inode = next_field(line, &mut cursor)?;

    let (start, end) = range.split_once('-')?;
    let start = u64::from_str_radix(start, 16).ok()?;
    let end = u64::from_str_radix(end, 16).ok()?;

    let tail = line[cursor..].trim();
    let path = if tail.is_empty() {
        None
    } else {
        Some(tail.to_string())
    };

    Some(Region {
        start,
        end,
        perms: perms.to_string(),
        offset: u64::from_str_radix(offset, 16).ok()?,
        path,
    })
}

/// Find the FIFA 17 process.
///
/// `comm` is the authority, NOT `cmdline`. Under Proton there are a dozen
/// helper processes (bash, umu-run, srt-bwrap, pv-adverb, proton, umu.exe)
/// whose command lines mention fifa17, and at least one of them
/// (`umu.exe /mnt/games/FIFA 17/_fifa17.exe`) is a convincing decoy. Only the
/// real game has `comm == "FIFA17.exe"`. Its `/proc/<pid>/exe` points at
/// wine64-preloader, which is expected and is not a reason to doubt the match.
pub fn find_pid(comm_name: &str) -> io::Result<i32> {
    let mut hits = Vec::new();
    for entry in fs::read_dir("/proc")? {
        let entry = entry?;
        let name = entry.file_name();
        let Some(name) = name.to_str() else { continue };
        let Ok(pid) = name.parse::<i32>() else {
            continue;
        };
        if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
            if comm.trim() == comm_name {
                hits.push(pid);
            }
        }
    }
    match hits.len() {
        0 => Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!("no process with comm == {comm_name:?}; is the game running? pass --pid to override"),
        )),
        1 => Ok(hits[0]),
        _ => Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("{} processes have comm == {comm_name:?}: {hits:?}; pass --pid to disambiguate", hits.len()),
        )),
    }
}

pub fn human(bytes: u64) -> String {
    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
    let mut value = bytes as f64;
    let mut unit = 0;
    while value >= 1024.0 && unit < UNITS.len() - 1 {
        value /= 1024.0;
        unit += 1;
    }
    if unit == 0 {
        format!("{bytes} B")
    } else {
        format!("{value:.2} {}", UNITS[unit])
    }
}
