#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read back what the client resolved for STAFF cards (head coach, GK coach,
physio, fitness coach) and grade every one HIT / MISS / WRONG-BRANCH / NO-MERGE.

READ-ONLY. Uses card_identity_probe.nodes() and the same /proc/PID/mem reader;
there is no write path in this file.

WHY A SEPARATE FILE FROM card_identity_probe
--------------------------------------------
card_identity_probe is a PLAYER tool and is actively wrong for staff:

  * classify() returns "NO-MERGE" for anything with cardtype != 1, so all four
    coach families come back NO-MERGE and the tool reports nothing.
  * F_NAME_KNOWN = 0xdd is the player knownAs string. For physio and fitness
    coach the merge writes RAW STAT BYTES into 0xdd..0xe3, and for managers
    talkrating/negotiation land at 0xe2/0xe3. So `known` is garbage for every
    non-player family and must never be read as a name there.
  * F_ATTRS (0x98..0xac) is only meaningful for head coach and GK coach, and
    even there the merge writes exactly ONE element.

THE MECHANISM (FUN_180141660, cardsdll.dll, 2129 bytes / 214 decompiled lines,
read end to end)
------------------------------------------------------------------------------
The merge switches on record+0x4c, which FUN_1800d8330 derives from the JSON
atom 0x6c cardsubtypeid alone:

    cardsubtypeid  ->  +0x4c  ->  table              key column
    5                  3          headcoachcards     carddbid == *(u32*)(rec+0x18)
    8                  4          fitnesscoachcards  carddbid == *(u32*)(rec+0x18)
    7                  5          physiocards        carddbid == *(u32*)(rec+0x18)
    6                  10         gkcoachcards       carddbid == *(u32*)(rec+0x18)
    4                  2          managercards       carddbid == *(u32*)(rec+0x18)
    0..3               1          players            playerid == rec+0x18 & 0xffffff

record+0x18 is atom 0x287 resourceId. THE COACH BRANCHES DO NOT MASK IT: unlike
FUN_180135890 (players), which does `& 0xffffff` twice, the four staff branches
and the manager branch pass the raw u32 straight into the `==` predicate. So a
version nibble in the high byte of resourceId breaks every staff lookup silently.

MISS FINGERPRINTS -- these are NOT uniform, contrary to earlier notes
--------------------------------------------------------------------
All four write firstname/lastname "DB Error", rating(+0xb4) 0x32 and rare(+0x58)
1, plus a TABLE-UNIQUE fallback assetid at +0x20. Only head coach and GK coach
write 0xf into the attribute array; physio writes 0xf into a BYTE at +0xdd, and
fitness coach writes no 0xf at all -- it writes fieldpos 1 / posbonus 7 /
amount 1 into +0xdd/+0xde/+0xdf.

The fallback assetid is what makes a negative interpretable: it names the branch
that ran. A head-coach id that comes back with assetId 3000259 means the FITNESS
branch ran, which is a different bug from "the id is wrong".

Two facts checked against the on-disk dumps in data/tables/ and used as oracles:
  * no row in any of the four tables has value == 50, so rating 0x32 is an
    unambiguous MISS for all four families;
  * no fitnesscoachcards row has (fieldpos, posbonus, amount) == (1, 7, 1), so
    that byte triple is an unambiguous MISS for fitness coach on its own.

Usage:
    python3 coach_probe.py                  # graded table + tally
    python3 coach_probe.py --json out.json
    python3 coach_probe.py --raw            # + hexdump of the first staff record
"""
import argparse
import json
import os
import struct
import sys

import card_identity_probe as P
import watch_club_model as W

TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                      "..", "data", "tables")

# cardsubtypeid -> (family, table file, +0x4c value, fallback assetid)
FAMILY = {
    5: ("headcoach",    "headcoachcards.json",     3, 0x1E8514),   # 2000148
    8: ("fitnesscoach", "fitnesscoachcards.json",  4, 0x2DC7C3),   # 3000259
    7: ("physio",       "physiocards.json",        5, 0x3D0992),   # 4000146
    6: ("gkcoach",      "gkcoachcards.json",      10, 0x895542),   # 9000258
    4: ("manager",      "managercards.json",       2, None),       # NO miss-fill
}

F_ATTRS_I32 = (0x98, 0x9C, 0xA0, 0xA4, 0xA8, 0xAC)   # head/GK coach boost slot
F_STAT_BYTES = 0xDD                                   # 0xdd..0xe3, 7 bytes
MISS_RATING = 0x32
MISS_NAME = "DB Error"


def load_tables():
    """{cardsubtypeid: {carddbid: row}} from the read-only on-disk dumps."""
    out = {}
    for sub, (_, fn, _, _) in FAMILY.items():
        path = os.path.join(TABLES, fn)
        try:
            with open(path) as f:
                rows = json.load(f)["rows"]
        except (IOError, OSError, ValueError):
            continue
        out[sub] = {r["carddbid"]: r for r in rows}
    return out


def read_staff(mem, node):
    buf = mem.read(node + P.REC, P.REC_LEN)
    if buf is None or len(buf) < P.REC_LEN:
        return None
    return {
        "node": node,
        "id": P.u32(buf, P.F_ID),
        "resourceId": P.u32(buf, P.F_RESOURCE),
        "assetId": P.u32(buf, P.F_ASSET),
        "cardtype": P.u32(buf, P.F_CARDTYPE),
        "subtype": P.u32(buf, P.F_SUBTYPE),
        "rating": P.u8(buf, P.F_RATING),
        "rare": P.u32(buf, 0x58),
        "tier": P.u32(buf, 0x54),
        "first": P.cstr(buf, P.F_NAME_FIRST, 0x10),
        "last": P.cstr(buf, P.F_NAME_LAST, 0x15),
        # what OUR json set and the coach branches never touch:
        "teamid": P.u32(buf, P.F_TEAM),
        "position": P.u8(buf, P.F_POSITION),
        "nation": P.u16(buf, P.F_NATION),
        "league": P.u32(buf, P.F_LEAGUE),
        # the two stat regions, read as raw numbers, never as a string:
        "attrs": [P.u32(buf, o) for o in F_ATTRS_I32],
        "stat_bytes": list(buf[F_STAT_BYTES:F_STAT_BYTES + 7]),
        "_raw": buf,
    }


def expected_tier(rating):
    """The tail of FUN_180141660, which runs for EVERY family including miss."""
    if rating is None:
        return None
    return 3 if rating >= 0x4B else (2 if rating >= 0x41 else 1)


def grade(c, tables):
    """HIT / MISS / WRONG-BRANCH / NO-MERGE / UNEXPECTED, plus a reason."""
    sub = c["subtype"]
    if sub not in FAMILY:
        return "NOT-STAFF", "cardsubtypeid %s is not a staff family" % sub
    name, _, want_ct, fallback = FAMILY[sub]
    c["family"] = name
    if c["cardtype"] != want_ct:
        return "NO-MERGE", ("cardtype %s, expected %d -- FUN_1800d8330 did not "
                            "map this subtype, so no query ran"
                            % (c["cardtype"], want_ct))

    # A miss anywhere names its own branch through the fallback assetid.
    for osub, (oname, _, _, ofb) in FAMILY.items():
        if ofb is not None and c["assetId"] == ofb and c["rating"] == MISS_RATING:
            if osub == sub:
                row = tables.get(sub, {}).get(c["resourceId"])
                if row is not None:
                    return "UNEXPECTED", ("MISS, but carddbid %d IS in %s -- the "
                                          "key or the field is wrong, not the id"
                                          % (c["resourceId"], name))
                return "MISS", "id %d absent from %s (as designed)" % (
                    c["resourceId"], name)
            return "WRONG-BRANCH", ("fallback assetid %d belongs to %s, but we "
                                    "sent cardsubtypeid %d (%s)"
                                    % (c["assetId"], oname, sub, name))

    if c["rating"] == MISS_RATING or MISS_NAME in (c["first"], c["last"]):
        return "UNEXPECTED", ("miss fingerprint without a known fallback assetid "
                              "(assetId=%s)" % c["assetId"])

    row = tables.get(sub, {}).get(c["resourceId"])
    if row is None:
        if sub == 4:
            return "SILENT", ("managercards writes NO miss-fill; a wrong id is "
                              "indistinguishable from a wrong mechanism")
        return "UNEXPECTED", ("no miss fingerprint, but id %d is absent from %s"
                              % (c["resourceId"], name))

    # HIT: every column we can see must agree with the on-disk row.
    bad = []
    if c["rating"] != row["value"]:
        bad.append("rating %s != value %s" % (c["rating"], row["value"]))
    if c["assetId"] != row["assetid"]:
        bad.append("assetId %s != assetid %s" % (c["assetId"], row["assetid"]))
    if c["rare"] != (1 if row["rare"] == 1 else 0):
        bad.append("rare %s != %s" % (c["rare"], row["rare"]))
    if c["tier"] != expected_tier(c["rating"]):
        bad.append("tier %s != %s" % (c["tier"], expected_tier(c["rating"])))
    if sub in (5, 6):                       # head coach / GK coach
        got = c["attrs"][row["attribute"]]
        if got != row["amount"]:
            bad.append("attrs[%d] %s != amount %s"
                       % (row["attribute"], got, row["amount"]))
    elif sub == 7:                          # physio, via FUN_180136270
        got = c["stat_bytes"][row["attribute"]]
        if got != row["amount"]:
            bad.append("+%#x %s != amount %s"
                       % (0xDD + row["attribute"], got, row["amount"]))
    elif sub == 8:                          # fitness coach: three columns at once
        for i, col in enumerate(("fieldpos", "posbonus", "amount")):
            if c["stat_bytes"][i] != row[col]:
                bad.append("+%#x %s != %s %s"
                           % (0xDD + i, c["stat_bytes"][i], col, row[col]))
    if bad:
        return "HIT-MISMATCH", "; ".join(bad)
    return "HIT", "every visible column agrees with %s row %d" % (
        name, c["resourceId"])


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--raw", action="store_true")
    ap.add_argument("--json", metavar="PATH")
    ap.add_argument("--all", action="store_true",
                    help="also list player/unknown cards instead of skipping them")
    a = ap.parse_args()

    tables = load_tables()
    if not tables:
        print("no table dumps under %s -- run tools/db_dump.py first" % TABLES)
        return 1

    pid = W.find_pid()
    if pid is None:
        print("FIFA17.exe is not running.")
        return 1
    base = W.dll_base(pid)
    if base is None:
        print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
        return 1
    mem = W.Mem(pid)
    obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
    if not obj:
        print("CardsDb singleton is NULL (no FUT session loaded).")
        return 1

    ns = P.nodes(mem, obj)
    print("pid=%d  CardsDb=%#x  size(+0x160e8)=%s  walked=%d"
          % (pid, obj, mem.i32(obj + W.TREE_SIZE), len(ns)))

    cards = []
    for n in ns:
        c = read_staff(mem, n)
        if not c:
            continue
        c["verdict"], c["why"] = grade(c, tables)
        cards.append(c)
    if not a.all:
        cards = [c for c in cards if c["verdict"] != "NOT-STAFF"]
    cards.sort(key=lambda c: (c["subtype"] or 0, c["resourceId"] or 0))

    print()
    print("%-13s %-9s %-8s %-4s %-4s %-4s %-22s %-13s %s"
          % ("family", "resource", "assetId", "rat", "tie", "rar", "name",
             "verdict", "why"))
    for c in cards:
        nm = ("%s %s" % (c["first"], c["last"])).strip()[:22]
        print("%-13s %-9s %-8s %-4s %-4s %-4s %-22s %-13s %s"
              % (c.get("family", "?"), c["resourceId"], c["assetId"],
                 c["rating"], c["tier"], c["rare"], nm, c["verdict"], c["why"]))

    tally = {}
    for c in cards:
        tally[c["verdict"]] = tally.get(c["verdict"], 0) + 1
    print("\n" + "  ".join("%s=%d" % kv for kv in sorted(tally.items())))
    print("\nfields the coach branches NEVER write, i.e. OURS on screen if they "
          "render at all:")
    for c in cards[:8]:
        print("  %-9s teamid=%-6s leagueId=%-6s nation=%-5s position=%s"
              % (c["resourceId"], c["teamid"], c["league"], c["nation"],
                 c["position"]))

    if a.raw and cards:
        b = cards[0]["_raw"]
        print("\nrecord %#x:" % (cards[0]["node"] + P.REC))
        for off in range(0, P.REC_LEN, 16):
            row = b[off:off + 16]
            print("  +%03x  %-47s  %s" % (
                off, " ".join("%02x" % x for x in row),
                "".join(chr(x) if 32 <= x < 127 else "." for x in row)))

    if a.json:
        for c in cards:
            c.pop("_raw", None)
        with open(a.json, "w") as f:
            json.dump(cards, f, indent=1)
        print("\nwrote %s" % a.json)

    print("\nfailed reads=%d" % mem.fails)
    return 0


if __name__ == "__main__":
    sys.exit(main())
