//! FIFA 17 client patcher — the ProtoSSL cert gates and the CardsDLL FUT store
//! patches, applied to a running `FIFA17.exe` through `/proc/<pid>/mem`.
//!
//! Port of `fifa17-recon/tools/autopatch.py`; that file is the specification and
//! every address, byte pattern and log line here is reproduced from it verbatim.
//! The store patches are re-applied on every tick because the game rewrites
//! those sites; the cert gates are applied once per pid.
//!
//! This module holds the constants, the two pure decision functions and the
//! parsers. The enforcement passes live in [`patch`], process access in
//! [`procmem`], timestamping in [`localtime`], and the watch loop in `main.rs`.

pub mod localtime;
pub mod logging;
pub mod patch;
pub mod procmem;

use std::fmt;

pub use logging::Logger;

/// `/proc/<pid>/comm` of the client we patch (exact match after trimming).
pub const CLIENT_COMM: &str = "FIFA17.exe";

/// Substring identifying the CardsDLL mapping in `/proc/<pid>/maps`.
pub const CARDSDLL_MARKER: &str = "CardsDLL";

// ---------------------------------------------------------------------------
// ProtoSSL certificate gates (absolute VAs in the unpacked FIFA17.exe image;
// present only once the packer has mapped the real code, hence the watch loop).
// ---------------------------------------------------------------------------

pub const GATE2: u64 = 0x1461361b0;
pub const GATE2_ORIG: [u8; 3] = [0x48, 0x89, 0x5c];
pub const GATE2_PATCH: [u8; 3] = [0x31, 0xc0, 0xc3];

pub const GATE1: u64 = 0x146132548;
pub const GATE1_ORIG: [u8; 6] = [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00];
pub const GATE1_PATCH: [u8; 6] = [0x90; 6];

// ---------------------------------------------------------------------------
// FUT store patches, expressed against the CardsDLL preferred image base and
// relocated to the live mapping base at runtime.
// ---------------------------------------------------------------------------

/// `mov eax, 1; ret` — force a predicate true.
pub const RET_TRUE: [u8; 6] = [0xb8, 0x01, 0x00, 0x00, 0x00, 0xc3];
/// Two `nop`s.
pub const NOP2: [u8; 2] = [0x90, 0x90];
/// `jmp +0x3f`, the one non-uniform store patch (site `0x180017543`).
pub const SHORT_JMP_3F: [u8; 2] = [0xeb, 0x3f];
/// CardsDLL's preferred image base; live address = `cbase + (va - IMG_BASE)`.
pub const IMG_BASE: u64 = 0x180000000;

/// Unconditional store patches, in the Python's insertion order — the order
/// decides the order of the `ENFORCED store patch` log lines.
///
/// The Python carries no per-site rationale for these eight sites, so none is
/// invented here; the addresses and bytes are reproduced verbatim.
pub const STORE_PATCHES: [(u64, &[u8]); 8] = [
    (0x1800f7fb0, &RET_TRUE),
    (0x1800fb850, &RET_TRUE),
    (0x180100500, &RET_TRUE),
    (0x180013cf0, &RET_TRUE),
    (0x180017543, &SHORT_JMP_3F),
    (0x180017487, &NOP2),
    (0x180017490, &NOP2),
    (0x1800175aa, &NOP2),
];

/// Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
/// tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
/// docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
///
/// When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
/// FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
/// category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
/// \[NULL+0x48\] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
/// positive-category resolution (EDI>0 branch) while routing zero/negative categories through
/// the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
///
/// CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
/// ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
/// DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
/// The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
/// invariant in the client-fix plan).
///
/// Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
/// already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
/// overwritten), so an unrecognised CardsDLL build is not patched.
///
/// Tuple layout: `(va, orig, patch)`. `JNZ 0x14869` -> `JG 0x14869`.
pub const STORE_PATCHES_GUARDED: [(u64, &[u8], &[u8]); 1] =
    [(0x180014858, &[0x75, 0x0f], &[0x7f, 0x0f])];

/// Capability advertised to the launcher/backend once the resolver guard is VERIFIED
/// live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
pub const EMPTY_MYPACKS_RESOLVER_CAPABILITY: &str = "fifa17.empty_mypacks_resolver";
pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1;

/// The guarded site whose verified enforcement backs the capability above.
pub const RESOLVER_GUARD_VA: u64 = 0x180014858;

/// Longest patch payload, so the watch loop can compare live bytes on the stack.
pub const MAX_PATCH_LEN: usize = RET_TRUE.len();

// Compile-time proof that the watch loop's stack buffers are large enough, so no
// slicing panic is reachable from the patch tables.
const _: () = {
    let mut i = 0;
    while i < STORE_PATCHES.len() {
        assert!(STORE_PATCHES[i].1.len() <= MAX_PATCH_LEN);
        i += 1;
    }
    let mut i = 0;
    while i < STORE_PATCHES_GUARDED.len() {
        assert!(STORE_PATCHES_GUARDED[i].1.len() <= MAX_PATCH_LEN);
        assert!(STORE_PATCHES_GUARDED[i].2.len() <= MAX_PATCH_LEN);
        assert!(STORE_PATCHES_GUARDED[i].1.len() == STORE_PATCHES_GUARDED[i].2.len());
        i += 1;
    }
};

/// Fail-closed decision for a guarded byte patch (see [`STORE_PATCHES_GUARDED`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuardedAction {
    /// Live bytes are already the patch; nothing to write.
    Noop,
    /// Live bytes are the known original; safe to apply.
    Patch,
    /// Unrecognised CardsDLL build — never blindly overwritten.
    Skip,
}

/// Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuardState {
    /// CardsDLL not mapped / guard not yet evaluated.
    NotAttempted,
    /// Live bytes == patch after enforcement (patch or noop).
    Verified,
    /// Neither original nor patched ([`GuardedAction::Skip`]).
    UnsupportedBuild,
    /// The `/proc/<pid>/mem` write failed.
    WriteFailed,
    /// Post-write re-read != patch.
    VerifyFailed,
}

impl GuardState {
    /// Wire spelling used in the `[store-guard] guard status=…` line the launcher reads.
    pub fn as_str(self) -> &'static str {
        match self {
            GuardState::NotAttempted => "NOT_ATTEMPTED",
            GuardState::Verified => "VERIFIED",
            GuardState::UnsupportedBuild => "UNSUPPORTED_BUILD",
            GuardState::WriteFailed => "WRITE_FAILED",
            GuardState::VerifyFailed => "VERIFY_FAILED",
        }
    }
}

impl fmt::Display for GuardState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Fail-closed decision for a guarded byte patch.
///
/// [`GuardedAction::Noop`] when the live bytes are already patched,
/// [`GuardedAction::Patch`] when they are the known original (safe to apply), or
/// [`GuardedAction::Skip`] for anything else — an unrecognised CardsDLL build
/// that must never be blindly overwritten.
pub fn guarded_action(cur: &[u8], orig: &[u8], patch: &[u8]) -> GuardedAction {
    if cur == patch {
        return GuardedAction::Noop;
    }
    if cur == orig {
        return GuardedAction::Patch;
    }
    GuardedAction::Skip
}

/// Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
///
/// Mirrors [`guarded_action`]'s decision, extended with post-write verification so the
/// caller advertises the capability only on VERIFIED. No `/proc` access — unit-testable.
///
/// - `cur_before == patch` -> VERIFIED           (already patched; [`GuardedAction::Noop`])
/// - `cur_before == orig`  -> WRITE_FAILED if the write raised, else VERIFIED when the
///   re-read is patch, else VERIFY_FAILED ([`GuardedAction::Patch`])
/// - otherwise             -> UNSUPPORTED_BUILD  ([`GuardedAction::Skip`])
///
/// [`GuardState::NotAttempted`] is never returned: it is the state a pid carries before
/// the guard is evaluated at all (CardsDLL not mapped yet), and this function is only
/// reached once live bytes have been read.
pub fn guard_state_after(
    cur_before: &[u8],
    orig: &[u8],
    patch: &[u8],
    wrote_ok: bool,
    cur_after: &[u8],
) -> GuardState {
    if cur_before == patch {
        return GuardState::Verified;
    }
    if cur_before == orig {
        if !wrote_ok {
            return GuardState::WriteFailed;
        }
        if cur_after == patch {
            return GuardState::Verified;
        }
        return GuardState::VerifyFailed;
    }
    GuardState::UnsupportedBuild
}

/// Live address of an image-relative patch site inside the mapped CardsDLL.
pub fn live_addr(cbase: u64, va: u64) -> u64 {
    cbase + (va - IMG_BASE)
}

/// Lowercase, unseparated hex — the spelling of `bytes.hex()` in the SKIP log line.
pub fn hex(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        // Two nibbles, no separator, no allocation per byte.
        const DIGITS: &[u8; 16] = b"0123456789abcdef";
        out.push(DIGITS[(b >> 4) as usize] as char);
        out.push(DIGITS[(b & 0x0f) as usize] as char);
    }
    out
}

/// True when a `/proc/<pid>/comm` body names the FIFA 17 client.
pub fn is_client_comm(comm: &str) -> bool {
    comm.trim() == CLIENT_COMM
}

/// Pid from a `/proc` directory entry name.
///
/// The Python globs `/proc/[0-9]*` and then `int()`s the name inside a bare
/// `except`, so a name has to start with a digit *and* be entirely numeric.
pub fn pid_from_proc_entry(name: &str) -> Option<u32> {
    if !name.as_bytes().first().is_some_and(u8::is_ascii_digit) {
        return None;
    }
    name.parse::<u32>().ok()
}

/// Base address of the first `CardsDLL` mapping in a `/proc/<pid>/maps` body.
///
/// Only the first matching line is considered, and a line whose base does not
/// parse yields `None` rather than falling through to the next mapping — the
/// Python's `int(...)` raises inside the `try` that returns `None`.
pub fn parse_cardsdll_base(maps: &str) -> Option<u64> {
    let line = maps.lines().find(|line| line.contains(CARDSDLL_MARKER))?;
    u64::from_str_radix(line.split('-').next()?, 16).ok()
}

/// `--launcher-pid <pid>` out of the argument list.
///
/// `Ok(None)` when the flag is absent, `Err(())` when it is present with a
/// missing or non-numeric value (the Python raises `SystemExit`). The value is
/// kept signed and un-clamped so the liveness check behaves exactly like the
/// Python's `os.path.exists(f"/proc/{launcher_pid}")` for odd inputs.
#[allow(clippy::result_unit_err)]
pub fn parse_launcher_pid<I, S>(args: I) -> Result<Option<i64>, ()>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let args: Vec<S> = args.into_iter().collect();
    let Some(idx) = args.iter().position(|a| a.as_ref() == "--launcher-pid") else {
        return Ok(None);
    };
    let value = args.get(idx + 1).ok_or(())?;
    value.as_ref().trim().parse::<i64>().map(Some).map_err(|_| ())
}

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

    const ORIG: &[u8] = &[0x75, 0x0f];
    const PATCH: &[u8] = &[0x7f, 0x0f];

    #[test]
    fn constants_match_the_python_spec() {
        assert_eq!(GATE2, 0x1461361b0);
        assert_eq!(GATE2_ORIG, [0x48, 0x89, 0x5c]);
        assert_eq!(GATE2_PATCH, [0x31, 0xc0, 0xc3]);
        assert_eq!(GATE1, 0x146132548);
        assert_eq!(GATE1_ORIG, [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00]);
        assert_eq!(GATE1_PATCH, [0x90, 0x90, 0x90, 0x90, 0x90, 0x90]);
        assert_eq!(IMG_BASE, 0x180000000);
        assert_eq!(hex(&RET_TRUE), "b801000000c3");
        assert_eq!(hex(&NOP2), "9090");

        // Site order is part of the log contract, so assert the whole table.
        let sites: Vec<(u64, String)> = STORE_PATCHES
            .iter()
            .map(|(va, data)| (*va, hex(data)))
            .collect();
        assert_eq!(
            sites,
            vec![
                (0x1800f7fb0, "b801000000c3".to_string()),
                (0x1800fb850, "b801000000c3".to_string()),
                (0x180100500, "b801000000c3".to_string()),
                (0x180013cf0, "b801000000c3".to_string()),
                (0x180017543, "eb3f".to_string()),
                (0x180017487, "9090".to_string()),
                (0x180017490, "9090".to_string()),
                (0x1800175aa, "9090".to_string()),
            ]
        );

        assert_eq!(STORE_PATCHES_GUARDED.len(), 1);
        let (va, orig, patch) = STORE_PATCHES_GUARDED[0];
        assert_eq!(va, 0x180014858);
        assert_eq!(hex(orig), "750f");
        assert_eq!(hex(patch), "7f0f");
        assert_eq!(RESOLVER_GUARD_VA, va);
        assert_eq!(EMPTY_MYPACKS_RESOLVER_CAPABILITY, "fifa17.empty_mypacks_resolver");
        assert_eq!(EMPTY_MYPACKS_RESOLVER_VERSION, 1);
        assert_eq!(MAX_PATCH_LEN, 6);
    }

    #[test]
    fn guarded_action_noops_when_already_patched() {
        assert_eq!(guarded_action(PATCH, ORIG, PATCH), GuardedAction::Noop);
    }

    #[test]
    fn guarded_action_patches_the_known_original() {
        assert_eq!(guarded_action(ORIG, ORIG, PATCH), GuardedAction::Patch);
    }

    #[test]
    fn guarded_action_skips_an_unrecognised_build() {
        // Fail-closed: an unknown CardsDLL build is never overwritten.
        assert_eq!(guarded_action(&[0x74, 0x0f], ORIG, PATCH), GuardedAction::Skip);
        assert_eq!(guarded_action(&[], ORIG, PATCH), GuardedAction::Skip);
        assert_eq!(guarded_action(&[0x75], ORIG, PATCH), GuardedAction::Skip);
    }

    #[test]
    fn guard_state_verified_when_already_patched() {
        // wrote_ok / cur_after are irrelevant on this branch.
        assert_eq!(
            guard_state_after(PATCH, ORIG, PATCH, false, &[]),
            GuardState::Verified
        );
    }

    #[test]
    fn guard_state_verified_after_a_successful_write() {
        assert_eq!(
            guard_state_after(ORIG, ORIG, PATCH, true, PATCH),
            GuardState::Verified
        );
    }

    #[test]
    fn guard_state_write_failed() {
        assert_eq!(
            guard_state_after(ORIG, ORIG, PATCH, false, ORIG),
            GuardState::WriteFailed
        );
    }

    #[test]
    fn guard_state_verify_failed() {
        // Write reported success but the re-read still shows the original …
        assert_eq!(
            guard_state_after(ORIG, ORIG, PATCH, true, ORIG),
            GuardState::VerifyFailed
        );
        // … or could not be re-read at all (the Python's `cur_after = b""`).
        assert_eq!(
            guard_state_after(ORIG, ORIG, PATCH, true, &[]),
            GuardState::VerifyFailed
        );
    }

    #[test]
    fn guard_state_unsupported_build() {
        assert_eq!(
            guard_state_after(&[0x74, 0x0f], ORIG, PATCH, true, PATCH),
            GuardState::UnsupportedBuild
        );
    }

    #[test]
    fn guard_state_spellings_are_the_launcher_contract() {
        assert_eq!(GuardState::NotAttempted.to_string(), "NOT_ATTEMPTED");
        assert_eq!(GuardState::Verified.to_string(), "VERIFIED");
        assert_eq!(GuardState::UnsupportedBuild.to_string(), "UNSUPPORTED_BUILD");
        assert_eq!(GuardState::WriteFailed.to_string(), "WRITE_FAILED");
        assert_eq!(GuardState::VerifyFailed.to_string(), "VERIFY_FAILED");
    }

    #[test]
    fn live_addr_relocates_against_the_image_base() {
        assert_eq!(live_addr(0x7f0000000000, 0x180014858), 0x7f0000014858);
        assert_eq!(live_addr(IMG_BASE, RESOLVER_GUARD_VA), RESOLVER_GUARD_VA);
    }

    #[test]
    fn hex_is_lowercase_and_unseparated() {
        assert_eq!(hex(&[0x00, 0x0f, 0xa5, 0xff]), "000fa5ff");
        assert_eq!(hex(&[]), "");
    }

    #[test]
    fn comm_matches_only_the_client() {
        assert!(is_client_comm("FIFA17.exe\n"));
        assert!(is_client_comm("FIFA17.exe"));
        assert!(!is_client_comm("fifa17.exe\n"));
        assert!(!is_client_comm("FIFA17.exe.bak\n"));
        assert!(!is_client_comm("wineserver\n"));
        assert!(!is_client_comm(""));
    }

    #[test]
    fn proc_entry_names_yield_only_numeric_pids() {
        assert_eq!(pid_from_proc_entry("1"), Some(1));
        assert_eq!(pid_from_proc_entry("41234"), Some(41234));
        assert_eq!(pid_from_proc_entry("self"), None);
        assert_eq!(pid_from_proc_entry("1abc"), None);
        assert_eq!(pid_from_proc_entry("+7"), None);
        assert_eq!(pid_from_proc_entry(""), None);
    }

    #[test]
    fn cardsdll_base_is_the_first_matching_mapping() {
        let maps = concat!(
            "140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n",
            "7f2a11c00000-7f2a11c9c000 r-xp 00000000 08:02 44 /home/u/CardsDLL_Win64_retail.dll\n",
            "7f2a12000000-7f2a12001000 r--p 00000000 08:02 45 /home/u/CardsDLL_second.dll\n",
        );
        assert_eq!(parse_cardsdll_base(maps), Some(0x7f2a11c00000));
    }

    #[test]
    fn cardsdll_base_absent_or_unparsable() {
        assert_eq!(parse_cardsdll_base(""), None);
        let no_dll = "140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n";
        assert_eq!(parse_cardsdll_base(no_dll), None);
        // A CardsDLL line whose base is not hex: fail, do not fall through.
        let broken = concat!(
            "zzzz-140001000 r--p 00000000 08:02 12 /home/u/CardsDLL.dll\n",
            "7f2a11c00000-7f2a11c9c000 r-xp 0 08:02 44 /home/u/CardsDLL.dll\n",
        );
        assert_eq!(parse_cardsdll_base(broken), None);
    }

    #[test]
    fn launcher_pid_parsing() {
        assert_eq!(parse_launcher_pid(Vec::<String>::new()), Ok(None));
        assert_eq!(parse_launcher_pid(["--other", "3"]), Ok(None));
        assert_eq!(parse_launcher_pid(["--launcher-pid", "4242"]), Ok(Some(4242)));
        assert_eq!(
            parse_launcher_pid(["-x", "--launcher-pid", "7", "--launcher-pid", "9"]),
            Ok(Some(7))
        );
        // Falsy in the Python (`if launcher_pid and ...`): parsed, never watched.
        assert_eq!(parse_launcher_pid(["--launcher-pid", "0"]), Ok(Some(0)));
        assert_eq!(parse_launcher_pid(["--launcher-pid", "-5"]), Ok(Some(-5)));
        assert_eq!(parse_launcher_pid(["--launcher-pid"]), Err(()));
        assert_eq!(parse_launcher_pid(["--launcher-pid", "abc"]), Err(()));
        assert_eq!(parse_launcher_pid(["--launcher-pid", ""]), Err(()));
    }
}
