#!/usr/bin/env python3
"""Freeze the Python Blaze responder's DISPATCH contract as replayable fixtures.

The crate-level fixtures in `openfut-protocol-blaze` pin the *codec*: given a
field tree, what bytes come out. This file pins the layer above: given an
inbound Fire2 frame and a session, **which frames go back, in what order**.

That is the whole contract of a Blaze adapter, and it is the thing a rewrite can
silently get wrong in ways a codec test cannot see — a missing post-login
notification, a reply where the oracle stays silent, notifications in the wrong
order, session state not carried between RPCs.

Every transaction is produced by calling the real
`blaze_responder_v3b.dispatch()`. Session state is threaded across a scripted
connection exactly as it would be on a live socket, so ordering-dependent
behaviour (preAuth captures the locale; login sets the auth code that
getAuthToken later returns) is captured rather than assumed.

Determinism: the oracle's clock is pinned and its PRNG seeded, and the
deployment-dependent addresses are set before import (the responder reads them
at import time). See the sibling generator in openfut-protocol-blaze.

NO SECRETS. The identity here (persona 33068179 / "CAGE") is the project's fixed
synthetic offline identity. Session keys are minted from a seeded PRNG.

Usage:  python3 fixtures/generate.py         (write)
        python3 fixtures/generate.py --check (verify committed files are current)
"""
from __future__ import annotations

import json
import os
import random
import sys
from collections import OrderedDict

HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.normpath(os.path.join(HERE, "..", "..", "fifa17-recon", "tools"))
if not os.path.isdir(TOOLS):
    sys.exit("cannot find the Python oracle at %s" % TOOLS)
sys.path.insert(0, TOOLS)

CHECK_ONLY = "--check" in sys.argv[1:]
# Internal mode: re-exec of this script with sentinel addresses, used to derive
# the templated client-config table (see emit_config_table).
CONFIG_TABLE_MODE = "--_config_table" in sys.argv[1:]
sys.argv = [sys.argv[0]]

# Sentinels substituted back into template tokens. Deliberately not IP-shaped so
# a stray literal cannot be mistaken for a real address.
SENTINELS = [
    ("ADVERTISE-SENTINEL", "{advertise}"),
    ("BIND-SENTINEL", "{bind}"),
    ("POWCONTENT-SENTINEL", "{pow_content_host}"),
    ("POWHOST-SENTINEL", "{pow_host}"),
]

if CONFIG_TABLE_MODE:
    os.environ["OPENFUT_ADVERTISE"] = "ADVERTISE-SENTINEL"
    os.environ["OPENFUT_BIND"] = "BIND-SENTINEL"
    os.environ["POW_CONTENT_HOST"] = "POWCONTENT-SENTINEL"
    os.environ["POW_HOST"] = "POWHOST-SENTINEL"
    import blaze_responder_v3b as _B  # noqa: E402
    out = {cfid: _B.client_config_for(cfid) for cfid in sorted(_B.CLIENT_CONFIGS)}
    out["__default__"] = _B.client_config_for("__no_such_section__")
    print(json.dumps(out))
    raise SystemExit(0)

# Pin deployment config BEFORE import — the responder snapshots these at import
# time into module globals used by the response builders.
#
# Distinct, obviously-fake values on purpose: if the Rust adapter hardcoded an
# address instead of reading its config, these make the failure loud rather than
# accidentally matching a loopback default.
ADVERTISE = "198.51.100.7"
BIND = "0.0.0.0"
POW_CONTENT_HOST = "198.51.100.7:8085"
POW_HOST = "198.51.100.7:8094"

os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
os.environ["OPENFUT_BIND"] = BIND
os.environ["POW_CONTENT_HOST"] = POW_CONTENT_HOST
os.environ["POW_HOST"] = POW_HOST

import heat2  # noqa: E402
import blaze_responder_v3b as B  # noqa: E402
from fut_account import ACCOUNT  # noqa: E402

FIXED_NOW = 1754870400
INT, STRING, STRUCT, LIST, MAP, BLOB = (
    heat2.INT, heat2.STRING, heat2.STRUCT, heat2.LIST, heat2.MAP, heat2.BLOB)

RECORDS = []


# ------------------------------------------------------------------ helpers

def req_frame(component, command, fields=None, msg_num=1, msg_type=None,
              user_index=0):
    """Build an inbound request frame the way the client would."""
    msg_type = B.MESSAGE if msg_type is None else msg_type
    payload = heat2.encode_tdf(fields) if fields else b""
    return B.fire2(component, command, msg_num, msg_type, payload,
                   user_index=user_index)


def tx(session, name, frame, note=""):
    """Run one frame through the real dispatcher and record what came back."""
    hdr = B.parse_fire2_header(frame)
    body = frame[16 + hdr["metadata_len"]:]
    fields = heat2.decode_tdf(body) if body else OrderedDict()
    out = B.dispatch(hdr, fields, body, session["sess"])

    RECORDS.append(OrderedDict((
        ("kind", "tx"),
        ("session", session["id"]),
        ("name", name),
        ("note", note),
        ("request_hex", frame.hex()),
        ("responses", [f.hex() for f in out]),
    )))
    return out


def new_session(sid):
    s = {"id": sid, "sess": B.Session()}
    RECORDS.append(OrderedDict((
        ("kind", "session"),
        ("id", sid),
        # Minted per connection by the oracle; the Rust side must be able to
        # inject it, because it appears in LoginResponse.SESS.KEY, the
        # UserAuthenticated push and PostAuthResponse.TELE.SESS and all three
        # must be the same string.
        ("session_key", s["sess"].session_key),
        ("account_locale", s["sess"].account_locale),
        ("service_name", s["sess"].service_name),
    )))
    return s


# ------------------------------------------------------------------ script

def build():
    RECORDS.append(OrderedDict((
        ("kind", "config"),
        ("advertise", ADVERTISE),
        ("bind", BIND),
        ("pow_content_host", POW_CONTENT_HOST),
        ("pow_host", POW_HOST),
        ("now", FIXED_NOW),
        ("identity", OrderedDict((
            ("persona_id", ACCOUNT.persona_id),
            ("persona_name", ACCOUNT.persona_name),
            ("user_id", ACCOUNT.user_id),
            ("ext_id", ACCOUNT.ext_id),
            ("email", ACCOUNT.email),
            ("namespace", ACCOUNT.NAMESPACE),
            ("client_platform", ACCOUNT.CLIENT_PLATFORM),
            ("persona_status", ACCOUNT.PERSONA_STATUS),
            ("user_session_type", ACCOUNT.USER_SESSION_TYPE),
            ("account_locale_int", ACCOUNT.account_locale_int),
            ("locale", ACCOUNT.locale),
            ("content_id", ACCOUNT.CONTENT_ID),
            ("entitlement_tag", ACCOUNT.ENTITLEMENT_TAG),
            ("entitlement_group", ACCOUNT.ENTITLEMENT_GROUP),
            ("title_id", ACCOUNT.TITLE_ID),
            ("client_id", ACCOUNT.CLIENT_ID),
            ("platform", ACCOUNT.PLATFORM),
            ("server_version", B.SERVER_VERSION),
        ))),
    )))

    # ================= main connection: the real boot order =================
    #
    # Mirrors what FIFA 17 actually does, because ordering is load-bearing:
    # preAuth captures the locale that later ALOC fields echo, and login sets
    # the auth code that getAuthToken returns afterwards.
    m = new_session("main")

    tx(m, "preauth", req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
        ("CDAT", (STRUCT, OrderedDict([
            ("IITO", (INT, 0)),
            ("LANG", (INT, 0x656E5553)),          # 'enUS'
            ("SVCN", (STRING, "fifa-2017-pc")),   # echoed back as INST
            ("TYPE", (INT, 0)),
        ]))),
        ("CINF", (STRUCT, OrderedDict([
            ("BSDK", (STRING, "15.1.1.3.0")),
            ("CLNT", (STRING, "FIFA17")),
            ("ENV",  (STRING, "prod")),
            ("LOC",  (INT, 0x656E5553)),
        ]))),
        ("FCCR", (STRUCT, OrderedDict([("CFID", (STRING, "BlazeSDK"))]))),
    ])), "first RPC; echoes SVCN as INST and captures LANG for ALOC")

    tx(m, "ping", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=2),
       "Util::ping -> STIM only")

    # Every section the responder knows, plus unknown ones. The known sections
    # each add their own rows on top of the shared FUT/RS4 base — OSDK_ROSTER in
    # particular carries the roster URL, itself a documented loading gate — so
    # covering only "BlazeSDK" would leave most of the table unverified.
    for cfid in ("BlazeSDK", "netres", "IdentityParams", "OSDK_CORE",
                 "OSDK_CLIENT", "OSDK_NUCLEUS", "OSDK_ROSTER", "OSDK_TICKER",
                 "OSDK_WEBOFFER", "OSDK_POW", "OSDK_ABUSE_REPORTING",
                 "OSDK_XMS_ABUSE_REPORTING", "UTAS", "FUT", "",
                 "TOTALLY_UNKNOWN"):
        tx(m, "fetch_config_%s" % (cfid or "empty"),
           req_frame(B.COMP_UTIL, B.CMD_FETCHCLIENTCONFIG,
                     OrderedDict([("CFID", (STRING, cfid))]), msg_num=3),
           "unknown CFIDs still get the shared FUT/POW rows")

    tx(m, "get_auth_token_before_login",
       req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=4),
       "no auth code yet -> synthesised OPENFUT-<key[:16]> token")

    tx(m, "logout_before_login", req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=5),
       "routine LoginStateLogout (state 500), NOT a failure; empty reply")

    tx(m, "login", req_frame(B.COMP_AUTH, B.CMD_LOGIN, OrderedDict([
        ("AUTH", (STRING, "OPENFUT-TEST-AUTHCODE")),
        ("EXTB", (BLOB, b"")),
        ("PNAM", (STRING, "")),
    ]), msg_num=6),
       "reply THEN three UserSessions pushes, in that order")

    tx(m, "get_auth_token_after_login",
       req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=7),
       "now echoes the login's AUTH verbatim")

    tx(m, "get_account", req_frame(B.COMP_AUTH, B.CMD_GETACCOUNT, msg_num=8),
       "the RPC behind 'Unable to retrieve account information'")
    tx(m, "get_persona", req_frame(B.COMP_AUTH, B.CMD_GETPERSONA, msg_num=9))
    tx(m, "list_personas", req_frame(B.COMP_AUTH, B.CMD_LISTPERSONAS, msg_num=10))

    for cmd, label in ((B.CMD_LISTUSERENTITLEMENTS2, "listUserEntitlements2"),
                       (0x20, "listEntitlements"),
                       (0x30, "listPersonaEntitlements2"),
                       (0x27, "grantEntitlement2")):
        tx(m, "entitlements_%s" % label,
           req_frame(B.COMP_AUTH, cmd, msg_num=11),
           "all four aliases return the same two ONLINE_ACCESS records")

    tx(m, "post_auth", req_frame(B.COMP_UTIL, B.CMD_POSTAUTH, msg_num=12),
       "TELE/TICK/UROP; TELE.SESS must equal the login session key")
    tx(m, "fetch_qos_config", req_frame(B.COMP_UTIL, 0x15, msg_num=13))
    tx(m, "user_settings_load",
       req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSLOAD, msg_num=14))
    tx(m, "user_settings_save",
       req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSSAVE, msg_num=15),
       "accepted and discarded; empty reply")
    tx(m, "set_client_state",
       req_frame(B.COMP_UTIL, B.CMD_SETCLIENTSTATE, msg_num=16))
    tx(m, "set_client_metrics",
       req_frame(B.COMP_UTIL, B.CMD_SETCLIENTMETRICS, msg_num=17))

    tx(m, "update_network_info",
       req_frame(B.COMP_USERSESSIONS, B.CMD_UPDATENETWORKINFO, msg_num=18),
       "empty reply PLUS an unsolicited ExtendedDataUpdate push")

    tx(m, "get_lists", req_frame(B.COMP_ASSOCLISTS, B.CMD_GETLISTS, msg_num=19))

    tx(m, "census_subscribe",
       req_frame(B.COMP_CENSUSDATA, B.CMD_SUBSCRIBETOCENSUSDATAUPDATES,
                 OrderedDict([("RSUB", (INT, 1))]), msg_num=20),
       "non-zero TimeValues or the client storms at ~30/s and hangs the FUT load")

    tx(m, "logout_after_login",
       req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=21),
       "session teardown after a login; still an empty reply")

    # ============================ fallback behaviour ========================
    f = new_session("fallbacks")

    tx(f, "transport_ping",
       req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=30, msg_type=B.PING),
       "msgType PING -> PING_REPLY with an empty body, whatever the command")

    for mt, label in ((B.REPLY, "reply"), (B.NOTIFICATION, "notification"),
                      (B.ERROR_REPLY, "error_reply"),
                      (B.PING_REPLY, "ping_reply")):
        tx(f, "ignores_%s" % label,
           req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=31, msg_type=mt),
           "not a request -> NO frames at all")

    tx(f, "unknown_command",
       req_frame(B.COMP_UTIL, 0x0FFF, msg_num=32),
       "unimplemented RPC still gets an EMPTY reply so the client cannot hang")
    tx(f, "unknown_component",
       req_frame(0x1234, 0x0001, msg_num=33),
       "same fallback for an entirely unknown component")

    tx(f, "user_index_is_echoed",
       req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=34, user_index=7),
       "a reply echoes component/command/msgNum/userIndex verbatim")

    # ================= locale echo on a non-default client ==================
    loc = new_session("locale")
    tx(loc, "preauth_de_locale",
       req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
           ("CDAT", (STRUCT, OrderedDict([
               ("LANG", (INT, 0x64654445)),   # 'deDE'
               ("SVCN", (STRING, "fifa-2017-pc-de")),
           ]))),
       ])),
       "a non-enUS client: SVCN echo AND the captured locale must both change")
    tx(loc, "login_with_de_locale",
       req_frame(B.COMP_AUTH, B.CMD_LOGIN, msg_num=41),
       "UserAuthenticated.ALOC must carry the captured deDE locale")


# ------------------------------------------------------------------- output

def emit_config_table():
    """Derive the fetchClientConfig tables as address-TEMPLATED data.

    These are 227-243 key/value rows per CFID, almost all of them the same URL.
    Hand-transcribing them into Rust would be 400 lines of string literals that
    nobody can review and one typo can break; deriving them mechanically from
    the oracle removes that whole class of error and keeps them regenerable.
    They are reverse-engineered *data*, not logic — the same reason
    `openfut-core` loads its content from `data/` rather than from source.

    The values are templated on {advertise}/{bind}/{pow_content_host}/{pow_host}
    so the adapter stays configurable; baking an address in here would recreate
    exactly the hardcoding the client/server split removed.

    Correctness is not assumed: the caller substitutes real addresses back in
    and diffs against the oracle. See verify_config_table.
    """
    import subprocess

    raw = subprocess.run(
        [sys.executable, os.path.abspath(__file__), "--_config_table"],
        capture_output=True, text=True, check=True,
        # Inherit nothing address-shaped; the child sets its own sentinels.
        env={k: v for k, v in os.environ.items()
             if not k.startswith(("OPENFUT_", "POW_", "FUT_"))},
    ).stdout
    table = json.loads(raw)

    def templatise(value):
        for sentinel, token in SENTINELS:
            value = value.replace(sentinel, token)
        # Collapse whole URLs to URL-level tokens where one exists, so the Rust
        # side builds them in exactly one place (AdapterConfig::utas_base and
        # friends) instead of re-deriving the shape here. Without this the
        # helpers become dead code and a hardcoded address in them goes
        # undetected — verified by mutation testing. Longest first.
        for whole, token in (
            ("http://{advertise}:8099/", "{utas_base}"),
            ("http://{bind}:42131", "{nucleus_base}"),
            ("http://{pow_content_host}", "{pow_content_url}"),
        ):
            if value == whole:
                return token
        return value

    return {cfid: [[k, templatise(v)] for k, v in rows]
            for cfid, rows in table.items()}


def verify_config_table(table):
    """Substitute the real addresses back and require the oracle's exact rows.

    This is what makes the templated table trustworthy rather than plausible.
    """
    subst = {
        "{utas_base}": "http://%s:8099/" % ADVERTISE,
        "{nucleus_base}": "http://%s:42131" % BIND,
        "{pow_content_url}": "http://%s" % POW_CONTENT_HOST,
        "{advertise}": ADVERTISE,
        "{bind}": BIND,
        "{pow_content_host}": POW_CONTENT_HOST,
        "{pow_host}": POW_HOST,
    }

    def render(v):
        for token, real in subst.items():
            v = v.replace(token, real)
        return v

    for cfid, rows in table.items():
        expected = B.client_config_for(
            "__no_such_section__" if cfid == "__default__" else cfid)
        got = [(k, render(v)) for k, v in rows]
        if got != [(k, v) for k, v in expected]:
            for (gk, gv), (ek, ev) in zip(got, expected):
                if (gk, gv) != (ek, ev):
                    sys.exit("config template mismatch in %s: %r -> %r, oracle "
                             "has %r -> %r" % (cfid, gk, gv, ek, ev))
            sys.exit("config template row-count mismatch in %s: %d vs %d"
                     % (cfid, len(got), len(expected)))
    print("config table verified against the oracle for %d sections"
          % len(table))


def frozen_clock():
    import time as _time
    original = _time.time
    _time.time = lambda: float(FIXED_NOW)
    return original, _time


def write(path, records):
    body = "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records)
    if CHECK_ONLY:
        if not os.path.exists(path):
            sys.exit("MISSING: %s has never been generated" % path)
        with open(path, "r", encoding="utf-8") as fh:
            if fh.read() != body:
                sys.exit("STALE: %s does not match the oracle; re-run without "
                         "--check" % path)
        print("current: %s (%d records)" % (os.path.basename(path), len(records)))
        return
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(body)
    print("wrote %s (%d records)" % (os.path.basename(path), len(records)))


def write_json(path, obj):
    body = json.dumps(obj, indent=1, sort_keys=True) + "\n"
    if CHECK_ONLY:
        if not os.path.exists(path):
            sys.exit("MISSING: %s has never been generated" % path)
        with open(path, "r", encoding="utf-8") as fh:
            if fh.read() != body:
                sys.exit("STALE: %s does not match the oracle" % path)
        print("current: %s" % os.path.basename(path))
        return
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(body)
    print("wrote %s (%d sections)" % (os.path.basename(path), len(obj)))


def main():
    # The oracle logs every dispatch to stdout; useful live, pure noise here.
    B.log = lambda *_a, **_k: None

    table = emit_config_table()
    verify_config_table(table)
    write_json(os.path.join(HERE, "client_config.json"), table)

    random.seed(0xB1A2E)
    original_time, time_mod = frozen_clock()
    try:
        build()
    finally:
        time_mod.time = original_time

    write(os.path.join(HERE, "blaze_transactions.jsonl"), RECORDS)
    txs = [r for r in RECORDS if r["kind"] == "tx"]
    frames = sum(len(r["responses"]) for r in txs)
    print("%d transactions, %d response frames, %d sessions"
          % (len(txs), frames,
             len([r for r in RECORDS if r["kind"] == "session"])))


if __name__ == "__main__":
    main()
