#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build the MIXED-CONTROL staff window: known-good coach ids interleaved with
known-bad ones, so a correct result and an incorrect one look different in the
same screenshot and in the same probe readback.

THIS FILE FIRES NOTHING. It prints/writes JSON. The integrate agent wires it in;
nobody else touches utas_server.py, fut_cards.py or fut_store.py.

WHY THIS SHAPE
--------------
The four coach branches of FUN_180141660 write a LOUD miss-fill -- firstname and
lastname "DB Error", rating 0x32, rare 1, and a TABLE-UNIQUE fallback assetid.
That gives three separable outcomes instead of two:

    HIT           real name, rating == the row's `value`, assetId == carddbid
    MISS          "DB Error", rating 50, assetId == this family's fallback
    WRONG-BRANCH  "DB Error", rating 50, assetId == ANOTHER family's fallback
    NO-MERGE      our sentinel rating survives, name empty -> the query never ran

so every failure mode says which one it is. That is what makes the negative
interpretable, and it is the reason coaches are the cheapest family to prove.

Two properties were checked against the on-disk dumps and both hold:
  * no row in any of the four tables has value == 50, so rating 50 can only be a
    miss -- it can never be a hit that happens to look like one;
  * no fitnesscoachcards row has (fieldpos, posbonus, amount) == (1, 7, 1), the
    fitness miss-fill triple at +0xdd/+0xde/+0xdf, so fitness has a second,
    fully independent oracle that does not depend on reading a name at all.

The known-bad ids are ids INSIDE each family's own carddbid band that are absent
from the table -- 195..207 such gaps exist per family, so a bad control is never
an out-of-range value the client might reject for an unrelated reason.

THE SENTINEL. Every item is sent with rating SENTINEL (=1), which no coach row
carries and which the miss-fill never writes. If a card comes back still holding
rating 1, the merge did not run at all; that is the NO-MERGE arm and it is a
different bug from either a hit or a miss.

DO NOT ROUTE THIS THROUGH FUT_ID_SWEEP. sweep_items() keeps itemType "player"
and varies only cardsubtypeid, and club_route answers a sweep BEFORE the ?type=
filter, so a sweep-borne staff experiment is confounded twice over.

WHICH SCREEN FIRES IT. The client's own ?type= taxonomy (FUN_18012ec50, 29 arms
+ default) does contain headcoach / gkcoach / physio / fitnesscoach / staff --
atoms 0x153 / 0x13f / 0x21d / 0x129 / 0x2dc. NONE of those five has ever been
seen on the wire. A grep of every capture and log in this repo finds exactly
three values: type=player (x14), type=manager (x2), type=custom (x1). So the
screen to aim this at is the STAFF tab, which sends type=manager, and the reason
these items reach it is that club_route already filters on cardsubtypeid -- not
on itemType and not on the type string -- keeping everything outside 0..3.

Usage:
    python3 coach_window.py                 # human-readable prediction table
    python3 coach_window.py --json items.json
    python3 coach_window.py --family headcoach --json one.json
"""
import argparse
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
SEEDS = os.path.join(HERE, "..", "data", "coach_seeds.json")

# Item ids for the experiment. Far above fut_store's ITEM_ID_BASE range so a
# stray record in the CardsDb map can always be attributed.
ID_BASE = 950000000
SENTINEL_RATING = 1


# A DISTINCT, unmistakable nation + league per family. This is the SECOND
# question the same window answers, and it is free: the four coach branches of
# FUN_180141660 never write nation (+0x148), leagueId (+0x154), teamid (+0x94) or
# position (+0x146), and none of the four tables even HAS a nation/league/team
# column, so these values cannot change the hit/miss outcome. Whatever a coach
# card shows for country or league therefore came from US.
#
# Both the good and the bad ids of a family carry the same pair, so a "DB Error"
# card flying a Brazilian flag is itself direct proof that the miss-fill leaves
# our fields alone.
#
# nation 14 (England) is deliberately AVOIDED: 14 is what the PLAYER miss-fill
# writes, and a value that doubles as a known failure fingerprint is not a probe.
FACE = {                                 # family -> (nationid, leagueid)
    "headcoach":    (54, 13),            # Brazil,  Premier League
    "gkcoach":      (45, 19),            # Spain,   Bundesliga
    "physio":       (27, 16),            # Italy,   Ligue 1
    "fitnesscoach": (21, 53),            # Germany, LaLiga Santander
}


def _item(item_id, carddbid, cardsubtypeid, nation=0, league=0):
    """One staff item.

    EXACTLY the field set fut_store._item() already builds and that this client
    is live-proven to parse. The only changes are cardsubtypeid and the ids. No
    new atom is introduced: the wire shape of a real staff item has NEVER been
    observed, and inventing one -- a scalar where the parser wants an object --
    is the change class that busy-loops the client at 0x1801c7f1a.

    resourceId carries NO version nibble. The staff branches compare
    `carddbid == *(u32*)(record+0x18)` on the RAW dword; players are the only
    family that masks with & 0xffffff. A high byte here breaks every lookup and
    does it silently.

    itemType stays "player" for the first run. The merge dispatches on
    cardsubtypeid alone, and club_route's ?type= filter already keys on
    cardsubtypeid (anything not in 0..3 survives a non-player type), so nothing
    needs itemType to be changed in order for these to reach the STAFF tab.
    Flipping it to the taxonomy name ("headcoach"/"gkcoach"/"physio"/
    "fitnesscoach", atoms 0x153/0x13f/0x21d/0x129) is a separate, later,
    one-variable experiment.
    """
    return {
        "id":                item_id,
        "resourceId":        carddbid,     # == carddbid, raw, no version byte
        "assetId":           carddbid,
        "cardassetid":       carddbid,
        "definitionId":      carddbid,
        "cardsubtypeid":     cardsubtypeid,
        "itemType":          "player",
        "rareflag":          1,            # overwritten by the merge either way
        "rating":            SENTINEL_RATING,
        "preferredPosition": "ST",
        "nation":            nation,
        "teamid":            0,
        "leagueId":          league,
        "playStyle":         250,
        # zeros so that, for head coach and GK coach, the ONE slot the merge
        # writes (attrs[row.attribute] = row.amount) stands out against five
        # untouched zeros -- that single write verifies two columns at once.
        "attributeList":     [{"index": i, "value": 0} for i in range(6)],
        "itemState":         "free",
        "owners":            1,
        "untradeable":       True,
        "contract":          7,
        "fitness":           99,
    }


def build(families=None):
    with open(SEEDS) as f:
        spec = json.load(f)
    fams = spec["families"]
    order = [f for f in ("headcoach", "gkcoach", "physio", "fitnesscoach")
             if families is None or f in families]

    items, predict, n = [], [], 0
    for fam in order:
        d = fams[fam]
        sub, fb = d["cardsubtypeid"], d["miss_fill_assetid"]
        nat, lg = FACE[fam]
        good = [r["carddbid"] for r in d["seeds"]]
        bad = d["bad_controls"]
        rows = {r["carddbid"]: r for r in d["seeds"]}

        # INTERLEAVE. The client pages the club (start=N&count=11 observed), so
        # good and bad must alternate or a page can come back all-good/all-bad
        # and prove nothing on its own screenshot.
        mixed, gi, bi = [], 0, 0
        while gi < len(good) or bi < len(bad):
            for _ in range(3):
                if gi < len(good):
                    mixed.append((good[gi], True)); gi += 1
            if bi < len(bad):
                mixed.append((bad[bi], False)); bi += 1

        for cid, is_good in mixed:
            iid = ID_BASE + n; n += 1
            items.append(_item(iid, cid, sub, nat, lg))
            if is_good:
                r = rows[cid]
                v = r["value"]
                p = {"id": iid, "family": fam, "carddbid": cid, "expect": "HIT",
                     "rating": v, "tier": 3 if v >= 75 else (2 if v >= 65 else 1),
                     "rare": r["rare"], "assetId": cid, "name": "a real person",
                     "face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)}
                if fam in ("headcoach", "gkcoach"):
                    p["attrs"] = "index %d == %d, other five == 0" % (
                        r["attribute"], r["amount"])
                elif fam == "physio":
                    p["stat_byte"] = "+%#x == %d" % (0xDD + r["attribute"],
                                                     r["amount"])
                else:
                    p["stat_bytes"] = "+0xdd/+0xde/+0xdf == %d/%d/%d" % (
                        r["fieldpos"], r["posbonus"], r["amount"])
            else:
                p = {"id": iid, "family": fam, "carddbid": cid, "expect": "MISS",
                     "rating": 50, "tier": 1, "rare": 1, "assetId": fb,
                     "name": "DB Error DB Error",
                     "face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)}
                if fam in ("headcoach", "gkcoach"):
                    p["attrs"] = "index 0 == 15, other five == 0"
                elif fam == "physio":
                    p["stat_byte"] = "+0xdd == 15"
                else:
                    p["stat_bytes"] = "+0xdd/+0xde/+0xdf == 1/7/1"
            predict.append(p)

        # ONE deliberate cross-family item per family: this family's BEST-KNOWN
        # good id sent under the NEXT family's cardsubtypeid. It must miss, and
        # its fallback assetid must name the OTHER table. That is the only item
        # in the window that can distinguish "the subtype picks the table" from
        # "the id band picks the table", and it is interpretable in both
        # directions: a real name here would refute the dispatch outright.
        other = order[(order.index(fam) + 1) % len(order)]
        if other != fam:
            osub = fams[other]["cardsubtypeid"]
            ofb = fams[other]["miss_fill_assetid"]
            iid = ID_BASE + n; n += 1
            # the cross-family item keeps the SOURCE family's face pair, so if
            # it ever renders it is visibly the head-coach flag on a gkcoach slot
            items.append(_item(iid, good[0], osub, nat, lg))
            predict.append({"id": iid, "family": "%s-id/%s-subtype" % (fam, other),
                            "carddbid": good[0], "expect": "MISS (cross-family)",
                            "rating": 50, "tier": 1, "rare": 1, "assetId": ofb,
                            "name": "DB Error DB Error",
                            "refutes": "a real name here means cardsubtypeid does "
                                       "NOT select the table"})
    return items, predict


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--json", metavar="PATH", help="write the item array")
    ap.add_argument("--family", action="append",
                    choices=["headcoach", "gkcoach", "physio", "fitnesscoach"])
    a = ap.parse_args()

    items, predict = build(a.family)
    nfam = len({p["family"] for p in predict if "/" not in p["family"]})
    print("%d items across %d familie(s), including %d deliberate cross-family "
          "controls. Every item carries rating=%d as the NO-MERGE sentinel.\n"
          % (len(items), nfam, sum(1 for p in predict if "/" in p["family"]),
             SENTINEL_RATING))
    print("%-11s %-24s %-9s %-20s %-5s %-4s %-8s %s"
          % ("id", "family", "carddbid", "expect", "rat", "tier", "assetId", "extra"))
    for p in predict:
        extra = p.get("attrs") or p.get("stat_byte") or p.get("stat_bytes") or ""
        print("%-11d %-24s %-9d %-20s %-5d %-4d %-8d %s"
              % (p["id"], p["family"], p["carddbid"], p["expect"], p["rating"],
                 p["tier"], p["assetId"], extra))
    n_hit = sum(1 for p in predict if p["expect"] == "HIT")
    print("\npredicted: HIT=%d  MISS=%d  (a run where all %d agree is the proof; "
          "any single disagreement names its own failure mode)"
          % (n_hit, len(predict) - n_hit, len(predict)))

    if a.json:
        with open(a.json, "w") as f:
            json.dump({"itemData": items, "predictions": predict}, f, indent=1)
        print("\nwrote %s" % a.json)
    return 0


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