#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""The MY CLUB stat vocabulary, censused from CardsDLL, and the body per mode.

HANDOVER MODULE -- nothing here is wired in. `utas_server.club_stats_route` is
owned by the integrate agent; this file is the spec in executable form. Import it
and call `stats_body()` / `staff_bonus_body()`, or lift the tables.

=============================================================================
1. THE VOCABULARY IS COMPLETE, AND IT IS AN ATOM TABLE, NOT A STRING TABLE
=============================================================================
`FUN_18012fd40` (1732 chars, decompiled and read END TO END -- every arm below is
transcribed from it, none elided) is the whole map. It does NOT compare strings:

    iVar1 = FUN_180180d00(<the 0x30-byte `type` buffer>);   // atom lookup
    switch (iVar1) { ... }                                  // 40 arms
    return 0;                                               // default

So the accepted vocabulary is exactly 40 ATOM IDS, and their spellings are the
atom names in docs/fut_atoms.tsv -- which is why a census is possible at all and
why "a filtered scan" is not needed: the function IS the census. Anything else
(including the real atoms `consumablesContract` 0xa6, `consumablesTraining` 0xa7,
`consumablesFitness` 0xa8 and the lowercase `leaguelogos` 0x18d, all of which
exist in the atom table and all of which are ABSENT from the switch) returns 0
and lands in bucket key 0, which no reader ever looks up. Unknown strings are
therefore inert, not fatal.

Coverage statement, per the standing rule about absences: the claim "these 40 and
no others" is a claim about a 1732-char function that was decompiled in full and
whose default arm is `return 0`. It is not an inference from a grep.

=============================================================================
2. STORAGE, AND WHY EVERY BODY MUST BE COMPLETE
=============================================================================
Deserializer 0x180130150 (7870 chars, read in full) writes
`store[contextValue][statId] = typeValue` where store = CardsDb + 0x1F8B0.

  * contextId (0xb6) is ONLY a guard: `if (contextId == 1 || contextId-5 < 5)`
    -> contextValue is forced to 0. contextId 3 is used below purely because it
    is outside that range and so preserves contextValue.
  * the storage key is contextValue ALONE. Nation 14, league 14 and team 14 share
    one bucket. One kind of context per response, never two.
  * element-local variables are cleared ONCE before the array loop, never inside
    it. Omit a key in element N and it silently inherits element N-1's value.
    EMIT ALL FOUR KEYS IN EVERY ELEMENT.
  * `type` is copied with FUN_180008120(buf, s, 0x30) -- a 48-byte buffer. The
    longest name we use is 40 chars. Fits.

THE WIPE IS REAL AND IT IS NOT IN THE DESERIALIZER. That is why one investigator
read the deserializer, found no clear, and reported "no wipe". The clear is in the
RESPONSE FACTORY, `FUN_18012f6d0`:

    store = CardsDb->vt[0x7f0]();
    FUN_180116240(store+0x30, *(store+0x48));   // destroy the whole outer tree
    store+0x38 = store+0x40 = store+0x38;       // head = root = sentinel
    store+0x48 = 0; store+0x50 = 0; store+0x58 = 0;
    ... then allocate "RS4:FutStickerBookStats2ServerResponse"

So EVERY Stats2 response erases the entire map before parsing. The map only ever
holds ONE mode's rows. `FUN_18012f680` is a second, standalone clear of the same
tree (session teardown).

AND THE ORDER IS DECIDED, MEASURED IN /tmp/utas_server.log: every MY CLUB entry
is `year` then `consumables`, in that order, 45 and 42 times this session. The
panels are therefore ALWAYS reading whatever we returned on `consumables`. A
consumables body that carries only consumable rows would blank the players,
staff, kits and badges rows of the very same tab strip. Hence: one union body,
served on every tab-strip mode.

(`FUN_18012fa90` also caches: if store+0x78/0x7c/0x80 already equal the requested
mode/arg1/arg2, no HTTP request is made at all. Re-entering the SAME screen twice
in a row is served from the map that is already there.)

=============================================================================
3. WHO READS WHAT.  Five consumers, and that is all five.
=============================================================================
Census method: byte-scan of .text for `call [reg+0x7f8]` / `[reg+0x800]` over all
16 registers (with and without REX). Exactly five functions contain such a call:

  FUN_180043b90   the club-stats data provider; switch on store+0x78 (the MODE)
  FUN_180094ce0   the MY CLUB eight-row summary panel
  FUN_180095360   the MY CLUB CONSUMABLES tab (7 rows + NUM_COLLECTED)
  FUN_180096670   the MY CLUB tab builder (staff tab, consumables tab, tiles)
  FUN_180097c70   the MY CLUB tile-detail panel

Call graph: FUN_180095660 -> FUN_180096670 -> {FUN_180095360, FUN_180097c70 ->
FUN_180094ce0}. FUN_180043b90 has no in-image caller (it is registered).

CRUCIAL, AND IT CORRECTS THE RECORD: the four MY CLUB panels do NOT switch on the
mode. They read the store unconditionally. Only FUN_180043b90 switches. So the
mode decides which of ITS cases runs, but the MY CLUB screen renders from
whatever the last response left behind, regardless of mode. This is why the union
body works and why "the client never asks for /club/stats/club" is survivable.

=============================================================================
4. THE CORRECTION THAT MATTERS MOST THIS ROUND
=============================================================================
REBUILD_RESEARCH S19 states: "Case 6 reads ids 0x3d CONTRACTS, 0x3e TRAINING and
0x40 FITNESS, which are exactly the three ids the type-string map cannot produce.
The consumables view is unsettable from this endpoint by construction."

THAT IS WRONG, and it is the reason the consumables tab is empty. 0x3d/0x3e/0x40
are read by case 5 (newcards), not case 6. Case 6 (consumables) reads:

    0x43 0x46 0x42 0x44 0x41 0x4b 0x4c 0x45 0x47 0x48 0x49 0x4d 0x4a 0x3c

FOURTEEN ids, and EVERY ONE OF THEM IS IN THE TYPE MAP. The consumables panel is
fully settable from /club/stats/consumables. The same fourteen (minus 0x48) drive
the MY CLUB consumables tab FUN_180095360. We have simply never sent one of them:
the body we serve on `consumables` today is the PLAYER stat set.

Three ids in the vocabulary are read by nobody: 0x29 kitsHome and 0x2a kitsAway
are read only by case 5, 0x2f leagueLogos only by case 5. Three ids are read but
CANNOT be set: 0x3d, 0x3e, 0x40 (no atom maps to them) -- case 5 only.

=============================================================================
5. THE STAFF BONUS ENDPOINT IS A SECOND, DISJOINT VOCABULARY
=============================================================================
GET club/stats/staff is FutStaffBonus, deserializer 0x18012b730 (2243 chars, read
in full), shape {"bonus":[{"type":str,"value":int}]}. It does NOT touch the Stats2
map (so it cannot wipe it), and it does NOT go through FUN_18012fd40. It calls

    store = CardsDb->vt[0x938]()        // == CardsDb + 0x5AF0, a flat struct
    FUN_18012b370(store, typeString, byteValue)

and FUN_18012b370 is a 22-arm atom switch writing ONE BYTE each at store+0x30 ..
store+0x45. Value path: INT getter 0x1801c79d0 -> FUN_1800d7b50, which clamps to
0..255 and returns 0 for anything <= 0. `type` goes into a 0x20 buffer; the
longest name in the vocabulary is 14 chars.

Those 22 bytes are the PERCENTAGES on the MY CLUB -> STAFF tab (FUN_180096670
case 8, customData 0x14): every row is published with LEFT_PERCENT/RIGHT_PERCENT
set to 1. The five COUNTS on the same tab come from the Stats2 store instead
(ids 0xb..0xf), so the staff tab needs BOTH endpoints answered.

=============================================================================
6. WHAT IS INFERRED RATHER THAN PROVEN
=============================================================================
FUN_180094ce0 iterates a vector at model+0x140 (stride 0x40): dword 0 is a
category code, dword +8 is the contextValue it looks up. Codes 1..7 and 9 do the
six per-context reads (0x28, 0x2d, 4, 3, 2, 5); code 8 reads stadia globally, 10
balls, 0x10 the six trophy ids, 0x12 the five staff ids. That +8 value is
INFERRED to be a nation id: FUN_180043b90 case 2 performs the identical six reads
on rows whose id it fetches as "NATION_ID", and FUN_180097c70 (this function's
caller) does the same. The vector's producer was not located, so this is a strong
structural inference, not a proof. See LIVE_TESTS at the bottom.
"""

# --------------------------------------------------------------------------
# THE VOCABULARY.  statId -> the JSON `type` string (== the atom name).
# Transcribed arm by arm from FUN_18012fd40. 40 entries, complete.
# --------------------------------------------------------------------------
VOCAB = {
    0x01: "players",                    # atom 0x238
    0x02: "playersBronze",              # atom 0x239
    0x03: "playersSilver",              # atom 0x23b
    0x04: "playersGold",                # atom 0x23a
    0x05: "rarePlayers",                # atom 0x272
    0x0A: "staff",                      # atom 0x2dc
    0x0B: "staffManager",               # atom 0x2dd
    0x0C: "staffHeadCoach",             # atom 0x2de
    0x0D: "staffGKCoach",               # atom 0x2e0   <- NOTE 0x2e0, not 0x2df
    0x0E: "staffPhysio",                # atom 0x2e1
    0x0F: "staffFitnessCoach",          # atom 0x2df   <- the pair is transposed
    0x14: "stadia",                     # atom 0x2d7
    0x1E: "balls",                      # atom 0x04f
    0x28: "kits",                       # atom 0x17c
    0x29: "kitsHome",                   # atom 0x17d
    0x2A: "kitsAway",                   # atom 0x17e
    0x2D: "badges",                     # atom 0x04b
    0x2E: "badgeDBid",                  # atom 0x04a
    0x2F: "leagueLogos",                # atom 0x18e  (NOT 0x18d `leaguelogos`)
    0x32: "trophies",                   # atom 0x340
    0x33: "trophiesOffline",            # atom 0x343
    0x34: "trophiesOnline",             # atom 0x344
    0x35: "trophiesFeaturedOffline",    # atom 0x341
    0x36: "trophiesFeaturedOnline",     # atom 0x342
    0x37: "trophiesSeasonOffline",      # atom 0x345
    0x38: "trophiesSeasonOnline",       # atom 0x346
    0x3C: "consumables",                # atom 0x0a5
    0x41: "consumablesHealing",         # atom 0x0af
    0x42: "consumablesContractPlayer",  # atom 0x0a9
    0x43: "consumablesTrainingPlayer",  # atom 0x0b3
    0x44: "consumablesFitnessPlayer",   # atom 0x0ab
    0x45: "consumablesPosition",        # atom 0x0b2
    0x46: "consumablesTrainingGk",      # atom 0x0b5
    0x47: "consumablesContractManager",             # atom 0x0aa
    0x48: "consumablesFormationManager",            # atom 0x0ad
    0x49: "consumablesTrainingManager",             # atom 0x0b4
    0x4A: "consumablesFitnessTeam",                 # atom 0x0ac
    0x4B: "consumablesTrainingPlayerPlayStyle",     # atom 0x0b0
    0x4C: "consumablesTrainingGkPlayStyle",         # atom 0x0b1
    0x4D: "consumablesTrainingManagerLeagueModifier",  # atom 0x0ae
}

# Read by a consumer but produced by NO atom -- unsettable from this endpoint.
UNSETTABLE = {0x3D: "CONTRACTS (case 5)", 0x3E: "TRAINING (case 5)",
              0x40: "FITNESS (case 5)"}

# --------------------------------------------------------------------------
# WHO READS WHICH ID.  (id, reader, on-screen row)
# --------------------------------------------------------------------------
#   FUN_180043b90 case 1  "club"        global  1,0x1e,0x28,0x14,0x0a,0x32
#   FUN_180043b90 case 2  "year"        global  0x1e,0x14,0xb,0xc,0xe,0xd,0xf,
#                                               0x33,0x34,0x35,0x36,0x37,0x38
#                         + per NATION_ID       2,3,4 (PLAYERS = their sum),5,
#                                               0x28,0x2d
#   FUN_180043b90 case 3  "country/id"  per LEAGUE_ID  2,3,4,5,0x28,0x2d
#   FUN_180043b90 case 4  "league/id"   per TEAM_ID    1,0x28,0x2e
#   FUN_180043b90 case 5  "newcards"    global  1,0x0a,0x14,0x1e,0x28,0x2f,0xb,
#                                               0xc,0xf,0xd,0xe,0x2d,0x29,0x2a,
#                                               0x3c,[0x3d,0x3e,0x40],0x41
#   FUN_180043b90 case 6  "consumables" global  0x43,0x46,0x42,0x44,0x41,0x4b,
#                                               0x4c,0x45,0x47,0x48,0x49,0x4d,
#                                               0x4a,0x3c
#   FUN_180094ce0 summary  per tile id  0x28,0x2d,4,3,2,5 ; global 0x14,0x1e,
#                                       0x33..0x38, 0xb..0xf
#   FUN_180095360 consumables tab global 0x46+0x43, 0x47+0x42, 0x4a+0x44, 0x41,
#                                        0x4c+0x4b, 0x4d, 0x49+0x45, 0x3c
#   FUN_180096670 staff tab      global 0xb,0xc,0xf,0xd,0xe  (+ the bonus bytes)
#   FUN_180097c70 tile detail    global 0x14,0x33..0x38,0xb..0xf,0x1e ;
#                                per id 2,3,4,0x28,0x2d,5

MODE_READS = {
    "club":        {"global": (0x01, 0x1E, 0x28, 0x14, 0x0A, 0x32), "context": None},
    "year":        {"global": (0x1E, 0x14, 0x0B, 0x0C, 0x0E, 0x0D, 0x0F,
                               0x33, 0x34, 0x35, 0x36, 0x37, 0x38),
                    "context": ("nation", (0x02, 0x03, 0x04, 0x05, 0x28, 0x2D))},
    "country":     {"global": (), "context": ("leagueId", (0x02, 0x03, 0x04, 0x05,
                                                           0x28, 0x2D))},
    "league":      {"global": (), "context": ("teamid", (0x01, 0x28, 0x2E))},
    "newcards":    {"global": (0x01, 0x0A, 0x14, 0x1E, 0x28, 0x2F, 0x0B, 0x0C,
                               0x0F, 0x0D, 0x0E, 0x2D, 0x29, 0x2A, 0x3C, 0x41),
                    "context": None},
    "consumables": {"global": (0x43, 0x46, 0x42, 0x44, 0x41, 0x4B, 0x4C, 0x45,
                               0x47, 0x48, 0x49, 0x4D, 0x4A, 0x3C), "context": None},
}

# The tab-strip modes: all four render the SAME MY CLUB screen, whose panels read
# the store unconditionally. They get the identical union body.
TAB_STRIP_MODES = ("", "year", "consumables", "club", "newcards")

# --------------------------------------------------------------------------
# THE STAFF-BONUS VOCABULARY.  atom name -> (store byte offset, screen row)
# Transcribed arm by arm from FUN_18012b370 (22 arms), rows from FUN_180096670
# case 8. Group codes are the staff-group table at 0x180203310, stride 0x18.
# --------------------------------------------------------------------------
STAFF_BONUS = {
    # manager group (code 2, count id 0x0b)
    "contract":      (0x30, "FUT_CONTRACTS"),
    "managerTalk":   (0x31, None),          # parsed, no reader found in case 8
    # fitness-coach group (code 4, count id 0x0f)
    "fitness":       (0x32, "FUT_FITNESS"),
    # physio group (code 5, count id 0x0e)
    "physioHead":     (0x33, "FUT_MC_HEAD"),
    "physioShoudler": (0x34, "FUT_MC_UPPERBODY"),   # sic, EA's spelling
    "physioArm":      (0x35, "FUT_MC_ARM"),
    "physioBack":     (0x36, "FUT_MC_BACK"),
    "physioHip":      (0x37, "FUT_MC_KNEE"),
    "physioLeg":      (0x38, "FUT_MC_LEG"),
    "physioFoot":     (0x39, "FUT_MC_FOOT"),
    # GK-coach group (code 10, count id 0x0d)
    "gkDiving":      (0x3A, "FUT_MC_DIVING"),
    "gkHandling":    (0x3B, "FUT_MC_HANDLING"),
    "gkKicking":     (0x3C, "FUT_MC_KICKING"),
    "gkReflexes":    (0x3D, "FUT_MC_REFLEXES"),
    "gkOneOnOne":    (0x3E, "FUT_MC_ACCELERATION"),  # label/name disagree; EA's
    "gkPositioning": (0x3F, "FUT_MC_POSITIONING"),
    # head-coach group (code 3, count id 0x0c)
    "pace":          (0x40, "FUT_MC_PACE"),
    "shooting":      (0x41, "FUT_MC_SHOOTING"),
    "passing":       (0x42, "FUT_MC_PASSING"),
    "dribbling":     (0x43, "FUT_MC_DRIBBLING"),
    "defending":     (0x44, "FUT_MC_DEFENDING"),
    "heading":       (0x45, "FUT_MC_HEADING"),
}

# --------------------------------------------------------------------------
# consumable card `kind` (fut_consumables.SUBTYPES) -> stat id.
# --------------------------------------------------------------------------
CONSUMABLE_KIND_STAT = {
    "player_contract":       0x42,
    "manager_contract":      0x47,
    "healing":               0x41,
    "player_fitness":        0x44,
    "squad_fitness":         0x4A,
    "gk_training":           0x46,
    "player_training":       0x43,
    "position_mod":          0x45,
    "player_playstyle":      0x4B,
    "gk_playstyle":          0x4C,
    "manager_league":        0x4D,
    "manager_formation_mod": 0x48,
    "formation_mod":         0x48,
    # DEAD_ZONE subtypes are never shipped and are counted nowhere.
}

# cardsubtypeid -> staff stat id (the merge's own families, see CARD_SYSTEM.md).
STAFF_SUBTYPE_STAT = {4: 0x0B, 5: 0x0C, 6: 0x0D, 7: 0x0E, 8: 0x0F}

PLAYER_SUBTYPES = (0, 1, 2, 3)

# --------------------------------------------------------------------------
# THE TWO UI GROUP TABLES, for reference. Both are (code, label, extra) triples
# at stride 0x18, and both are indexed by the switch in FUN_180096670.
#
#   consumables tab, table at 0x180203260, switch case 0xb:
#     code 0x00  FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED         "training"
#     code 0x01  FUT_MYCLUB_CONSUMABLES_CONTRACT_EARNED         "contracts"
#     code 0x04  FUT_MYCLUB_CONSUMABLES_FITNESS_EARNED          "fitness"
#     code 0x03  FUT_MYCLUB_CONSUMABLES_HEALING_EARNED          "healing"
#     code 0x17  FUT_MYCLUB_CONSUMABLES_PLAYSTYLE_EARNED        "playStyle"
#     code 0x18  FUT_MYCLUB_CONSUMABLES_MANAGER_LEAGUE_EARNED   "managerLeagueModifier"
#     code 0x11  FUT_MYCLUB_CONSUMABLES_TACTIC_TRAINING_EARNED  "position"
#
#   staff tab, table at 0x180203310, switch case 8:
#     code 0x02  FUT_MYCLUB_MANAGERS      count 0x0b   bonus row FUT_CONTRACTS
#     code 0x03  FUT_MYCLUB_HEADCOACHES   count 0x0c   6 attribute bonus rows
#     code 0x04  FUT_MYCLUB_FITNESS       count 0x0f   bonus row FUT_FITNESS
#     code 0x0a  FUT_MYCLUB_GKCOACHES     count 0x0d   6 GK bonus rows
#     code 0x05  FUT_MYCLUB_PHYSIO        count 0x0e   7 body-part bonus rows
#
# THOSE GROUP NAMES ARE NOT ?type= VALUES. The club query taxonomy is a separate
# 30-arm atom switch, FUN_18012ec50, and it reads:
#   0 any, 1 player, 2 manager, 3 headcoach, 4 fitnesscoach, 5 physio,
#   6 development, 7 custom, 8 unlocks, 9 gkcoach, 10 staff, 11 badge, 12 kit,
#   13 stadium, 14 ball, 15 equippables, 16 leaguelogos, 17 offlinetrophy,
#   18 onlinetrophy, 19 featuredofflinetrophy, 20 featuredonlinetrophy,
#   21 allofflinetrophy, 22 allonlinetrophy, 23 healing, 24 contract,
#   25 training, 26 misc, 27 playerdefender, 28 playermidfielder,
#   29 playerforward.
# So last round's type=contract / training / healing / development arms were
# CORRECTLY NAMED. The empty consumables tab is not a naming bug on that route.
# --------------------------------------------------------------------------


def _row(ctx_id, ctx_val, stat_id, value):
    """One stat element. All four keys, always -- see the note about element-local
    variables not being reset between elements."""
    return {"contextId": int(ctx_id), "contextValue": int(ctx_val),
            "type": VOCAB[stat_id], "typeValue": int(value)}


def global_counts(items, staff_counts=None):
    """{statId: value} for the global bucket, from the items the club holds.

    `items` is the club item list (utas_server STORE.items() shape).
    `staff_counts` optionally overrides the staff tally with the synthetic
    overlay's counts, keyed by cardsubtypeid 4..8.
    """
    try:
        import fut_consumables
    except Exception:
        fut_consumables = None

    players = [i for i in items if i.get("cardsubtypeid", 0) in PLAYER_SUBTYPES]
    rating = lambda i: i.get("rating") or 0
    c = {
        0x01: len(players),
        0x04: len([i for i in players if rating(i) >= 75]),
        0x03: len([i for i in players if 65 <= rating(i) < 75]),
        0x02: len([i for i in players if 0 < rating(i) < 65]),
        0x05: len([i for i in players if i.get("rareflag")]),
    }

    # staff, per family
    staff = dict(staff_counts) if staff_counts else {}
    if not staff:
        for i in items:
            st = i.get("cardsubtypeid", 0)
            if st in STAFF_SUBTYPE_STAT:
                staff[st] = staff.get(st, 0) + 1
    for st, sid in STAFF_SUBTYPE_STAT.items():
        c[sid] = staff.get(st, 0)
    c[0x0A] = sum(c[s] for s in STAFF_SUBTYPE_STAT.values())

    # consumables, per family
    for sid in (0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
                0x4A, 0x4B, 0x4C, 0x4D):
        c[sid] = 0
    total_cons = 0
    if fut_consumables is not None:
        for i in items:
            rec = fut_consumables.BY_SUBTYPE.get(i.get("cardsubtypeid", 0))
            if rec is None:
                continue
            total_cons += 1
            sid = CONSUMABLE_KIND_STAT.get(rec["kind"])
            if sid:
                c[sid] = c.get(sid, 0) + 1
    c[0x3C] = total_cons

    # club items. Honest zeros unless the club really holds them; cardtype 9 has
    # no merge arm, so we cannot classify these from the item record and the club
    # holds none today. Every one of these is READ by some panel, so it must be
    # present or the panel keeps the previous screen's number.
    for sid in (0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F,
                0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38):
        c.setdefault(sid, 0)
    return c


def context_rows(items, kind):
    """Per-context rows for one screen. `kind` is "", "country" or "league"."""
    field = {"": "nation", "country": "leagueId", "league": "teamid"}.get(kind)
    if not field:
        return [], 0
    players = [i for i in items if i.get("cardsubtypeid", 0) in PLAYER_SUBTYPES]
    rating = lambda i: i.get("rating") or 0
    ctxs = sorted({i.get(field) for i in players if i.get(field) is not None})
    rows = []
    for ctx in ctxs:
        sel = [i for i in players if i.get(field) == ctx]
        if field == "teamid":
            # case 4 reads 1, 0x28, 0x2e. FUN_180043b90 publishes 0x2e raw as
            # BADGES_AVAILABLE; the tab builder FUN_180096670 publishes the same
            # id as `(uint)(iVar6 != 0)` -- a HAS-A-BADGE boolean. Any non-zero
            # therefore reads as 1 on one screen and as itself on the other.
            vals = [(0x01, len(sel)), (0x28, 0), (0x2E, 0)]
        else:
            # cases 2 and 3 COMPUTE players as gold+silver+bronze and never read
            # id 1. The tier counts are mandatory, not decoration.
            vals = [(0x04, len([i for i in sel if rating(i) >= 75])),
                    (0x03, len([i for i in sel if 65 <= rating(i) < 75])),
                    (0x02, len([i for i in sel if 0 < rating(i) < 65])),
                    (0x05, len([i for i in sel if i.get("rareflag")])),
                    (0x28, 0), (0x2D, 0)]
        rows += [_row(3, ctx, sid, v) for sid, v in vals]
    return rows, len(ctxs)


def stats_body(mode, items, staff_counts=None):
    """The FutStickerBookStats2 body for GET ut/%s/club/stats/<mode>.

    `mode` is the URL tail: "", "year", "consumables", "club", "newcards",
    "country/<id>", "league/<id>". The id in the URL says WHICH SCREEN, never
    which bucket: country/<n> renders a list of LEAGUES and league/<n> a list of
    TEAMS, and the reader looks each row up by that row's own id.
    """
    parts = (mode or "").split("/")
    head = parts[0]
    glob = global_counts(items, staff_counts)
    stats = [_row(1, 0, sid, val) for sid, val in sorted(glob.items())]
    if len(parts) >= 2 and parts[1].isdigit() and head in ("country", "league"):
        ctx, _n = context_rows(items, head)
    else:
        ctx, _n = context_rows(items, "")
    return {"stat": stats + ctx}


def staff_bonus_body(bonuses):
    """The FutStaffBonus body for GET ut/%s/club/stats/staff.

    `bonuses` is {atom name: 0..255}. Names not in STAFF_BONUS are dropped rather
    than sent: an unknown name is inert (FUN_18012b370 falls through) but sending
    one proves nothing and widens the surface. An empty dict yields {"bonus":[]},
    which is a different thing from today's {} -- see the live test.
    """
    out = []
    for name, val in bonuses.items():
        if name not in STAFF_BONUS:
            continue
        v = int(val)
        out.append({"type": name, "value": max(0, min(255, v))})
    return {"bonus": out}


# --------------------------------------------------------------------------
# LIVE TESTS (a human fires these; nothing here mutates a save)
# --------------------------------------------------------------------------
# T1  CONSUMABLES TAB.  Serve the union body on every tab-strip mode, then open
#     MY CLUB -> CONSUMABLES.
#     positive: the seven rows read TRAINING 42, CONTRACT 13, FITNESS 6,
#               HEALING 21, PLAYSTYLE 24, MANAGER LEAGUE 0, TACTIC TRAINING 20,
#               and the header count reads 126 (with the 126-item shelf armed).
#     negative: all seven still 0 -> the tab is not reading the Stats2 store at
#               all and FUN_180095360 is not the renderer. That is interpretable
#               and it kills the whole approach, which is why it is worth firing.
#
# T2  NATION KEYING (settles the one inference in this file).  With the union
#     body live, read FUT_MYCLUB_PLAYERS_EMPLOYED on the MY CLUB summary.
#     positive: 205 (the sum over all 29 nation buckets).
#     negative: 0 while the ENGLAND -> Premier League row still reads 17 -> the
#               tile vector at model+0x140 is NOT keyed by nation id, and the
#               producer of that vector has to be found. Also interpretable.
#
# T3  STAFF BONUS.  Answer club/stats/staff with staff_bonus_body({"pace": 7,
#     "contract": 3}) and open MY CLUB -> STAFF.
#     positive: the head-coach group shows PACE 7% and the manager group shows
#               CONTRACTS 3%.
#     negative: both read 0% -> the bytes at CardsDb+0x5AF0+0x40/+0x30 are not
#               what the tab renders, and the 22-name table is wrong about its
#               consumer (it is not wrong about the parser).
#     Two distinct values on two distinct groups on purpose: one number could be
#     a coincidence, two in the right places cannot.

if __name__ == "__main__":
    import json
    import os
    import sys
    here = os.path.dirname(os.path.abspath(__file__))
    sys.path.insert(0, here)
    prof = json.load(open(os.path.join(here, "fifa17_profile.json")))
    items = prof["items"]
    print("club: %d items" % len(items))
    for m in ("year", "consumables", "country/14", "league/13"):
        b = stats_body(m, items)
        g = [r for r in b["stat"] if r["contextId"] == 1]
        c = [r for r in b["stat"] if r["contextId"] == 3]
        print("  %-12s %3d rows (%d global, %d context)"
              % (m, len(b["stat"]), len(g), len(c)))
    print("\nglobal bucket, non-zero rows:")
    for r in stats_body("year", items)["stat"]:
        if r["contextId"] == 1 and r["typeValue"]:
            print("    %-42s %d" % (r["type"], r["typeValue"]))
