#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Offline regression tests for the non-player card families.

WHY THIS IS A SEPARATE SUITE. tools/test_fut_contract.py talks to a LIVE server over
HTTP and imports nothing from the server's own code -- that is what lets it certify a
future non-Python implementation of the same reversed spec. These checks are the
opposite kind: they are unit tests of the item BUILDERS, they must run against the
working tree rather than against whatever process happens to be listening on 8099, and
they must not require the live client to be restarted. Mixing them into the contract
suite would have broken both properties.

Run:  python3 tools/test_card_families.py     (exit 0 = all pass, stdlib only)
"""
import os, sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)

_fail = []
_pass = 0


def check(name, cond, detail=""):
    global _pass
    if cond:
        _pass += 1
    else:
        _fail.append("%s  %s" % (name, detail))


def raises(name, fn, *a, **kw):
    try:
        fn(*a, **kw)
    except Exception:
        check(name, True)
        return
    check(name, False, "did NOT raise")


# ---------------------------------------------------------------- the rareflag trap
def test_item_rareflag_trap():
    """fut_store._item() must not stamp rareflag 1 on a Player Fitness card.

    FUN_1801bfac0 case 5 takes the SQUAD-fitness branch when
    `(cardsubtypeid == 0xdc) || (*(int *)(rec + 0x58) == 1)`, and rec+0x58 IS the
    rareflag atom 0x271. So a rare 219 renders as a Squad Fitness card -- a silent
    corruption of the first fitness card we would ever serve.
    """
    import fut_store
    d = fut_store._item(100000001, 20801, 94, "LW", 38, 53, 243, [90, 93, 82, 91, 33, 80])
    # 1. THE PLAYER DICT IS BYTE-IDENTICAL TO BEFORE THE FIX. Key order included:
    #    every existing caller passes 8 positional args, so both new keyword params
    #    take their defaults.
    check("player item key ORDER unchanged",
          list(d) == ["id", "resourceId", "assetId", "cardassetid", "definitionId",
                      "cardsubtypeid", "itemType", "rareflag", "rating",
                      "preferredPosition", "nation", "teamid", "leagueId", "playStyle",
                      "attributeList", "itemState", "owners", "untradeable",
                      "contract", "fitness"], repr(list(d)))
    check("player item VALUES unchanged",
          d == {"id": 100000001, "resourceId": 20801, "assetId": 20801,
                "cardassetid": 20801, "definitionId": 20801, "cardsubtypeid": 0,
                "itemType": "player", "rareflag": 1, "rating": 94,
                "preferredPosition": "LW", "nation": 38, "teamid": 243, "leagueId": 53,
                "playStyle": 250,
                "attributeList": [{"index": i, "value": v} for i, v in
                                  enumerate([90, 93, 82, 91, 33, 80])],
                "itemState": "free", "owners": 1, "untradeable": True,
                "contract": 7, "fitness": 99}, repr(d))
    # 2. THE GUARD ITSELF. This is the assertion that fails without the fix.
    f = fut_store._item(1, 5002001, 55, "LW", 0, 0, 0, [0] * 6,
                        cardsubtypeid=219, rareflag=1)
    check("subtype 219 forced to rareflag 0 (the squad-fitness trap)",
          f["rareflag"] == 0, repr(f["rareflag"]))
    check("subtype 219 keeps its cardsubtypeid", f["cardsubtypeid"] == 219,
          repr(f["cardsubtypeid"]))
    # 3. THE GUARD IS SCOPED. It must not touch any neighbouring subtype.
    for sub in (218, 220, 4, 5, 0):
        g = fut_store._item(1, 1, 50, "LW", 0, 0, 0, [0] * 6,
                            cardsubtypeid=sub, rareflag=1)
        check("subtype %d keeps rareflag 1 (guard is 219-only)" % sub,
              g["rareflag"] == 1, repr(g["rareflag"]))


# ------------------------------------------------------------------- consumables
def test_consumables():
    import fut_consumables as fc

    check("172 cardtype-6 subtypes", len(fc.SUBTYPES) == 172, len(fc.SUBTYPES))
    dead = [r for r in fc.SUBTYPES if r["kind"] == "DEAD_ZONE"]
    check("28 dead zones", len(dead) == 28, len(dead))
    check("dead zones are exactly the known set",
          sorted(r["cardsubtypeid"] for r in dead) ==
          [58, 59, 60, 68, 69, 70, 87, 88, 89, 90, 111, 112, 113, 114, 115, 116, 117,
           118, 119, 120, 203, 204, 205, 206, 207, 208, 209, 210])
    # A dead-zone card renders as a plausible Squad Training (Pace) card with amount
    # 0 -- there is no DB-Error analogue -- so the builder must refuse it outright.
    raises("consumable_item refuses a dead zone (89)", fc.consumable_item, 1, 89)
    raises("consumable_item refuses a non-cardtype-6 subtype", fc.consumable_item, 1, 4)
    # Omitting `amount` stamps (byte)-1 into rec+0xbf, and FUN_1801a8040 sign-extends,
    # so the card reads "-1" rather than 0. Refuse rather than ship that.
    raises("training without amount is refused", fc.consumable_item, 1, 61)
    raises("healing without amount is refused", fc.consumable_item, 1, 211)
    raises("contract without contract= is refused", fc.consumable_item, 1, 201)
    raises("rare Player Fitness (219) is refused", fc.consumable_item, 1, 219,
           amount=20, rareflag=1)
    # 220 is ALWAYS squad fitness (0xdc is the first half of the branch test), so a
    # rare one is merely rare, not corrupted.
    check("rare Squad Fitness (220) is allowed",
          fc.consumable_item(1, 220, amount=10, rareflag=1)["rareflag"] == 1)

    shelf = fc.starter_consumables(fc.CONSUMABLE_ID_BASE)
    check("starter shelf is non-empty", len(shelf) > 0, len(shelf))
    check("no dead zone on the shelf",
          not [i for i in shelf
               if fc.BY_SUBTYPE[i["cardsubtypeid"]]["kind"] == "DEAD_ZONE"])
    check("shelf ids are unique", len({i["id"] for i in shelf}) == len(shelf))
    check("shelf ids are clear of the save's 1e8 space",
          all(i["id"] >= fc.CONSUMABLE_ID_BASE for i in shelf))
    check("no 219 on the shelf is rare",
          all(i["rareflag"] == 0 for i in shelf if i["cardsubtypeid"] == 219))
    for i in shelf:
        r = fc.BY_SUBTYPE[i["cardsubtypeid"]]
        if "amount" in r["needs"]:
            check("subtype %d carries amount" % i["cardsubtypeid"], "amount" in i)
        if "contract" in r["needs"]:
            check("subtype %d carries contract" % i["cardsubtypeid"], "contract" in i)
        # Player-only fields must never appear: rec+0x146 and rec+0x98.. survive and
        # are read by the generic view-model.
        check("subtype %d sends no player-only fields" % i["cardsubtypeid"],
              not ({"preferredPosition", "attributeList", "nation", "leagueId",
                    "teamid", "playStyle", "fitness"} & set(i)), repr(sorted(i)))
    # The excluded families, each for a named reason (see CORE_KINDS).
    kinds = {fc.BY_SUBTYPE[i["cardsubtypeid"]]["kind"] for i in shelf}
    for banned in ("manager_formation_mod", "formation_mod", "manager_league"):
        check("%s is NOT shipped" % banned, banned not in kinds)
    # ?type= routing
    check("type=contract -> only categories 2/3",
          {fc.BY_SUBTYPE[i["cardsubtypeid"]]["category"]
           for i in fc.items_for_type("contract")} <= {2, 3})
    check("type=training -> only category 0",
          {fc.BY_SUBTYPE[i["cardsubtypeid"]]["category"]
           for i in fc.items_for_type("training")} == {0})
    check("an unknown type gets nothing", fc.items_for_type("player") == [])
    check("def_for resolves an EA carddbid", fc.def_for(5001001) is not None)
    check("def_for returns None for a player asset", fc.def_for(20801) is None)


# ----------------------------------------------------------------- coach families
def test_coaches():
    import fut_coaches as cc

    check("four coach families", len(cc.FAMILIES) == 4)
    for fam, (sub, ct, table, miss) in cc.FAMILIES.items():
        rows = cc.COACHES[fam]
        check("%s row count" % fam,
              len(rows) == {"headcoach": 124, "gkcoach": 121, "physio": 51,
                            "fitnesscoach": 115}[fam], len(rows))
        # The oracle: rating 0x32 can ONLY ever be a miss, in every family.
        check("%s has no row with value 50 (the miss-fill rating)" % fam,
              not [r for r in rows if r["rating"] == 50])
        check("%s ids are all < 2^24 (the key is a raw u32 but the artwork masks)" % fam,
              all(r["carddbid"] < (1 << 24) for r in rows))
        check("%s carddbids are unique" % fam,
              len({r["carddbid"] for r in rows}) == len(rows))
    # fitness coach's miss triple must not exist as a real row, or its second oracle
    # would be ambiguous.
    check("no fitnesscoach row is (fieldpos 1, posbonus 7, amount 1)",
          not [r for r in cc.COACHES["fitnesscoach"]
               if (r["fieldpos"], r["posbonus"], r["amount"]) == (1, 7, 1)])
    # tier() is the binary's own tail, not a convention.
    check("tier boundaries", (cc.tier(64), cc.tier(65), cc.tier(74), cc.tier(75))
          == (1, 2, 2, 3))

    raises("coach_item refuses a non-coach subtype", cc.coach_item, 1, 4, 1000509)
    for miss in cc.MISS_FILL_IDS:
        raises("coach_item refuses miss-fill assetid %d" % miss,
               cc.coach_item, 1, 5, miss)

    seeds = cc.starter_coaches(cc.COACH_ID_BASE)
    check("24 starter coaches (six per family)", len(seeds) == 24, len(seeds))
    check("starter ids unique", len({i["id"] for i in seeds}) == len(seeds))
    for i in seeds:
        check("starter coach %d is a real row" % i["resourceId"],
              (i["cardsubtypeid"], i["resourceId"]) in cc.BY_ID)
        check("starter coach %d has a non-zero id (no id -> NO merge at all)"
              % i["resourceId"], i["id"] != 0)
        check("coach %d sends no invented nation/league/team" % i["resourceId"],
              not ({"nation", "leagueId", "teamid", "preferredPosition",
                    "attributeList", "rating", "rareflag", "assetId"} & set(i)),
              repr(sorted(i)))
    subs = {i["cardsubtypeid"] for i in seeds}
    check("all four families represented", subs == {5, 6, 7, 8}, repr(sorted(subs)))

    # ?type= routing: each family's own arm serves only that family; staff (arm 10)
    # serves all four.
    for fam, (sub, _ct, _tbl, _miss) in cc.FAMILIES.items():
        got = cc.items_for_type(fam)
        check("type=%s serves only subtype %d" % (fam, sub),
              got and {i["cardsubtypeid"] for i in got} == {sub},
              repr({i["cardsubtypeid"] for i in got}))
    check("type=staff serves all four families",
          {i["cardsubtypeid"] for i in cc.items_for_type("staff")} == {5, 6, 7, 8})
    check("an unknown type gets nothing", cc.items_for_type("player") == [])
    # Ids must not shift depending on which arm asked, or the same card would enter
    # the client's CardsDb map twice under two handles.
    check("item ids are stable across arms",
          {i["resourceId"]: i["id"] for i in cc.items_for_type("staff")} ==
          {i["resourceId"]: i["id"]
           for fam in cc.FAMILIES for i in cc.items_for_type(fam)})


# --------------------------------------------------------------------- managers
def test_managers():
    import fut_staff as fs

    check("417 manager cards", len(fs.MANAGERS) == 417, len(fs.MANAGERS))
    check("carddbid == assetid band 1000001..1001552",
          (fs.MANAGERS[0]["carddbid"], fs.MANAGERS[-1]["carddbid"]) == (1000001, 1001552))
    # The `manager` join: 747 rows but 746 distinct managerids -- managerid 107 is
    # duplicated with an empty-name row, which used to win the dict comprehension.
    check("297 managers carry a real name (the 107 duplicate resolved)",
          sum(1 for m in fs.MANAGERS if m["name"]) == 297,
          sum(1 for m in fs.MANAGERS if m["name"]))
    check("carddbid 1000107 keeps Slutskiy, not the empty row",
          fs.BY_ID[1000107]["name"] and fs.BY_ID[1000107]["teamid"] == 315,
          repr(fs.BY_ID[1000107]))
    check("ten starter managers", len(fs.STARTER_MANAGERS) == 10)
    for c in fs.STARTER_MANAGERS:
        m = fs.BY_ID[c]
        check("starter manager %d has a name" % c, bool(m["name"]))
        check("starter manager %d has nation/league/team" % c,
              m["nation"] and m["leagueId"] and m["teamid"])
    it = fs.manager_item(1, 1000509)
    check("manager resourceId is the RAW carddbid (the merge does not mask)",
          it["resourceId"] == 1000509)
    check("manager subtype 4", it["cardsubtypeid"] == 4)
    check("manager sends nation/leagueId (rec+0xde/+0xe0 are OURS alone)",
          it["nation"] == 45 and it["leagueId"] == 53, repr(it))
    check("manager sends no rating/rareflag/position/attrs (all overwritten or read)",
          not ({"rating", "rareflag", "preferredPosition", "attributeList",
                "assetId", "definitionId"} & set(it)), repr(sorted(it)))


# ------------------------------------------------------- overlay id-space hygiene
def test_id_spaces():
    """The four id spaces must not overlap: a collision would make two different cards
    share an item id, and the client keys its CardsDb map on it."""
    import fut_consumables as fc, fut_coaches as cc, fut_staff as fs
    spaces = {
        "save":       (100000000, 100999999),
        "consumable": (fc.CONSUMABLE_ID_BASE, fc.CONSUMABLE_ID_BASE + 999999),
        "coach":      (cc.COACH_ID_BASE, cc.COACH_ID_BASE + 999999),
        "manager":    (fs.OVERLAY_ID_BASE, fs.OVERLAY_ID_BASE + 999999),
        "probe":      (fs.PROBE_ID_BASE, fs.PROBE_ID_BASE + 999999),
        "sweep":      (900000000, 900999999),
    }
    names = sorted(spaces)
    for i, a in enumerate(names):
        for b in names[i + 1:]:
            lo1, hi1 = spaces[a]
            lo2, hi2 = spaces[b]
            check("%s and %s id spaces are disjoint" % (a, b),
                  hi1 < lo2 or hi2 < lo1, "%r %r" % (spaces[a], spaces[b]))


def main():
    for t in (test_item_rareflag_trap, test_consumables, test_coaches, test_managers,
              test_id_spaces):
        try:
            t()
        except Exception as e:
            _fail.append("%s raised %s: %s" % (t.__name__, type(e).__name__, e))
    print("\n%d checks passed, %d failed" % (_pass, len(_fail)))
    for f in _fail:
        print("  FAIL:", f)
    return 0 if not _fail else 1


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