#!/usr/bin/env python3
"""OpenFUT — POW / EASFC server for FIFA 17 (clean-room).

WHY THIS EXISTS
---------------
The FUT hub's "EA FC servers are unreachable / PRESS Q TO RE-CONNECT" banner is
NOT the FUT/UTAS layer, NOT Blaze and NOT Origin/LSX -- all three are healthy in
our live logs while the banner is showing. It is the EASFC layer, implemented in
`powdll_Win64_retail.dll` (1.1 MB, UNPACKED and string-rich -- unlike the Denuvo
-packed FIFA17.exe, this one can actually be reversed).

POW is a THIRD HTTP API, alongside Blaze and UTAS, that we have never served:
    api      pas.gt.easfc.ea.com:8094     paths `pow/...`
    content  content.lt.easfc.ea.com:8080 paths `pow/imgAssets/...`, artAssets, ...
Neither hostname is in /etc/hosts nor in the iptables DNAT, so every POW call dies
at DNS resolution and the client raises the reconnect prompt.

REVERSED FROM powdll (PE base 0x180000000, Ghidra project /tmp/pow/powproj):
  * FUN_18005a460 -- POW config init. Reads, through the SAME client-config store
    that already feeds us ROSTERUPDATE_URL (cfg->vtbl[0x30] = getString with a
    default): "FIFA_POW_URL", "FIFA_POW_CONTENT_SERVER_URL", and "POW_IS_ON".
    It picks an http:// vs https:// prefix (PTR_s_http____18010aee0 /
    PTR_s_https____18010aee8). So POW can be redirected purely by serving those
    keys from blaze_responder_v3b.py -- no /etc/hosts and no root required.
  * FUN_18005cb40 -- the health-check / reconnect handler. Issues
    `pow/healthcheck/system/all` via the request builder FUN_18005e780, then sets
    the POW connection state at POWmgr[0x6ac]:
        1 = connected/online      3 = disconnected (raises the prompt)
    It is also the site that fires the `POWService::PowReconnect` FE event.
  * FUN_18005c970 fires POWService::PowBlazeDisconnected,
    FUN_1800a8590 fires POWService::TriggerPleaseConnectMsg,
    FUN_1800ad090 references TXT_EASFC_RECONNECT_PROMPT (the banner string).

STATUS: the REQUEST side is mapped (58 `pow/...` path templates extracted from the
binary, see PATHS below). The RESPONSE schemas are NOT yet reversed -- powdll's
parsers have not been walked. So this server's job right now is to be a faithful,
loud LOGGER: bind the ports, answer every request in a way that cannot wedge the
client, and write the exact method/path/headers/body of everything POW asks for to
/tmp/pow_server.log. That capture is what turns the response schemas from guesswork
into reversing targets, exactly as the UTAS log did for the squad work.

MODES (POW_MODE):
  log   (default) every request -> 200 {} (assets -> 404), everything logged.
                  Nothing is asserted about our capabilities; safest first run.
  serve            additionally answers the handful of paths whose shape we can
                   infer (auth/healthcheck/counts) with minimal plausible bodies.
                   Use this only AFTER a capture run, and expect to iterate.

Ports: POW_ADDR (default 127.0.0.1:8094), POW_CONTENT_ADDR (default 127.0.0.1:8080).
"""
import datetime, json, os, re, sys, threading, http.server

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
    from fut_account import ACCOUNT          # username/persona, single source
except Exception:                            # keep the logger usable standalone
    ACCOUNT = None

LOG = os.environ.get("POW_LOG", "/tmp/pow_server.log")
# Default flipped to `serve` once the schemas were recovered from powdll: `log`
# answers every list with {}, which makes the catalogue pager spin forever (983
# requests in 84s, live-captured). POW_MODE=log is still available for a fresh
# capture run.
MODE = os.environ.get("POW_MODE", "serve")
API_ADDR = os.environ.get("POW_ADDR", "127.0.0.1:8094")
CONTENT_ADDR = os.environ.get("POW_CONTENT_ADDR", "127.0.0.1:8080")


def _split(hostport, default_port):
    host, _, port = hostport.partition(":")
    return (host or "127.0.0.1", int(port or default_port))


def log(m):
    line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
    print(line, flush=True)
    try:
        with open(LOG, "a") as f:
            f.write(line + "\n")
    except Exception:
        pass


# Every `pow/...` path template found in powdll_Win64_retail.dll. Kept verbatim so
# the log can flag an incoming path that is NOT in this list (i.e. our extraction
# missed something) rather than silently lumping it in with the known set.
PATHS = [
    "pow/auth", "pow/healthcheck/system/all", "pow/nucleus/entitlements",
    "pow/v2/activity", "pow/activity/count", "pow/bank/user/account",
    "pow/bank/currency/%s/cap/info", "pow/chal/user/prog", "pow/communication/all",
    "pow/communication/all/countUnread", "pow/communication/count",
    "pow/communication/type/%s", "pow/communication/attributes/type/%s",
    "pow/components/EASFCWidget", "pow/gamechange/gamechangetype/%s",
    "pow/inventory/item", "pow/inventory/item/list",
    "pow/lvl/user/tiergp/%s/tiertp/%s", "pow/lvl/weight/tiergp/%s/tiertp/%s",
    "pow/message", "pow/mm", "pow/mm/game/%s/message/list",
    "pow/news/count/unread", "pow/news/opt", "pow/news/user",
    "pow/pfyc/user", "pow/pfyc/user/club", "pow/pfyc/user/prefs/shareinfo",
    "pow/store/game/%s/catalog/list", "pow/store/game/%s/catalog/%d/item/list",
    "pow/store/gift/list", "pow/user/friends",
    "pow/users/info/tiergp/%s/tiertp/%s",
]
_KNOWN = [re.compile("^/?" + re.escape(p).replace(r"\%s", "[^/]+").replace(r"\%d", r"\d+")
                     .replace(r"\%lld", r"\d+") + "$") for p in PATHS]


# Asset roots are PREFIXES in the binary ("pow/imgAssets/", plus %d-templated file
# names), so match them by prefix rather than exact template or every art fetch
# trips the unknown-path flag.
_ASSET_PREFIXES = ("pow/imgAssets/", "pow/artAssets/", "pow/facebook/",
                   "pow/cacheresponse/")


def is_known_path(path):
    p = path.split("?", 1)[0]
    if p.lstrip("/").startswith(_ASSET_PREFIXES):
        return True
    return any(rx.match(p) for rx in _KNOWN)


def _username():
    if ACCOUNT is not None:
        return ACCOUNT.persona_name
    return os.environ.get("POW_USERNAME", "CAGE")


def _persona_id():
    if ACCOUNT is not None:
        return ACCOUNT.persona_id
    return int(os.environ.get("POW_PERSONA_ID", "33068179"))


# ---- response schemas, recovered from powdll ---------------------------------
# Every key below is a LITERAL STRING in powdll_Win64_retail.dll, i.e. a name the
# client's parser actually compares against. Addresses are the literal's location.
#
#   level parser FUN_180094700 groups exactly these seven:
#     level(0x1800c9862) exp(0x1800e1974) currLevelExpMin(0x1800e1978)
#     currLevelExpMax(0x1800e1988) isMaxLevel(0x1800e1998) dailyXpCap(0x1800e1bb0)
#     currency(0x1800c96e0)
#   paging:  itemsTotal(0x1800e18c0)  numItems(0x1800e17d8)  totalCount(0x1800c9218)
#   bank (contiguous field-name table, i.e. a reflection-style schema):
#     currencies(0x1800c96b8) currency(0x1800c96e0) currencyName(0x1800c96f0)
#     funds(0x1800c98f0) fundsBalance(0x1800c98f8) fundsCap(0x1800c9908)
#     fundsCapInfo(0x1800c9918) fundsEarned(0x1800c9928)
#     accountBalance(0x1800c92e0) balance(0x1800ccb28) numCurrency(0x1800e1808)
#     pow_funds(0x1800c7f30) -- the currency NAME the client asks for by
#     `pow/bank/currency/pow_funds/cap/info` (live-captured).
#
# DELIBERATELY NOT INVENTED: personaId / personaName / userId / sessionId /
# displayName / personaList do NOT exist as literals anywhere in powdll, so an
# auth response carrying them would be parsed as nothing. An earlier draft of this
# file asserted exactly those keys -- it was wrong and is corrected here.
POW_CURRENCY = "pow_funds"


# ---- ENVELOPE PROBE ----------------------------------------------------------
# The field NAMES are certain (literals in powdll). The top-level ENVELOPE is not:
# serving the level record at the JSON root was live-tested and IGNORED -- the hub
# still read "LVL: 0/0". The wrapper is not statically recoverable so far: the name
# tables (FUN_180094700 etc.) are plain `return names[idx]` helpers with no schema
# descriptor attached, and their only other xrefs are .pdata unwind entries.
#
# So probe empirically, but in ONE launch instead of one-per-candidate: emit the
# record at the root AND under every plausible wrapper key at once. A reflection
# parser ignores members it has no field for (the same SKIP behaviour CardsDLL's
# deserializers use), so the extra copies are inert -- whichever wrapper the client
# looks for, it finds. Wrapper candidates are the envelope-ish literals that exist
# in powdll: data(0x1800c818c) result(0x1800ce3fc) items(0x1800c9e28)
# content(0x1800c9378) status(0x1800dd2c0) success(0x1800daed8) message(0x1800cef28).
#
# Set POW_ENVELOPE=root to serve ONLY the bare record (no probe copies) once the
# right wrapper is known.
_ENVELOPE = os.environ.get("POW_ENVELOPE", "probe")


def _wrap(record, list_key="items"):
    """Root record + probe copies under each candidate wrapper."""
    if _ENVELOPE == "root":
        return dict(record)
    body = dict(record)
    for k in ("data", "result", "content"):
        body[k] = dict(record)
    body[list_key] = [dict(record)]
    body["numItems"] = 1
    body["itemsTotal"] = 1
    body["totalCount"] = 1
    body["status"] = "OK"
    body["success"] = True
    return body


def level_record():
    """The seven fields powdll's level name table (FUN_180094700) enumerates."""
    a = ACCOUNT
    return {
        "level": a.pow_level if a else 1,
        "exp": a.pow_exp if a else 0,
        "currLevelExpMin": 0,
        "currLevelExpMax": a.pow_exp_max if a else 1000,
        "isMaxLevel": False,
        "dailyXpCap": 0,
        "currency": POW_CURRENCY,
    }


def level_body():
    """pow/lvl/user/tiergp/%s/tiertp/%s -> the hub's "LVL: x/y" widget."""
    return _wrap(level_record(), list_key="levels")


def bank_body():
    """pow/bank/user/account -> the EASFC credit counter next to the cart."""
    a = ACCOUNT
    funds = a.pow_funds if a else 0
    cap = a.pow_funds_cap if a else 100000
    entry = {
        "currencyName": POW_CURRENCY,
        "currency": POW_CURRENCY,
        "funds": funds,
        "fundsBalance": funds,
        "fundsEarned": 0,
        "fundsCap": cap,
        "balance": funds,
        "accountBalance": funds,
    }
    body = _wrap(entry, list_key="currencies")
    body["numCurrency"] = 1
    return body


def _empty_page():
    """Any paginated list. The count fields are what TERMINATE the pager.

    Not cosmetic: with a bare {} the catalogue pager never learns the result count
    and re-requests offset=0&count=49 forever -- 983 identical requests in 84s on
    the first live capture, still 432 with only itemsTotal/numItems set. So emit
    the FULL count vocabulary that powdll's list envelope reader FUN_180094560
    enumerates (numItems 0x1800e17d8, numOwnedItems 0x1800e17e8, numLockedItems
    0x1800e17f8, numCurrency 0x1800e1808) plus the totals the catalog-item reader
    FUN_1800945c0 knows (itemCount 0x1800e18b0, itemsTotal 0x1800e18c0,
    itemsOwned 0x1800e18d0), and an empty array under every plausible list key."""
    body = {
        "numItems": 0, "numOwnedItems": 0, "numLockedItems": 0, "numCurrency": 0,
        "itemCount": 0, "itemsTotal": 0, "itemsOwned": 0,
        "totalCount": 0, "count": 0, "offset": 0,
        "status": "OK", "success": True,
    }
    for k in ("items", "list", "data", "result", "content", "catalogs",
              "currencies", "entries"):
        body[k] = []
    return body


def serve_body(path, method):
    """MODE=serve. Bodies built only from keys verified present in powdll (above).
    Returns None to fall through to {}."""
    p = path.split("?", 1)[0].lstrip("/")
    if p == "pow/healthcheck/system/all":
        # FUN_18005cb40 issues this first, then sets POWmgr[0x6ac] 1=connected /
        # 3=disconnected. Live: the client went ONLINE with a bare {} here, so the
        # state is driven by transport success, not by this body. Keep it minimal.
        return {}
    if p.startswith("pow/lvl/"):                       # user + weight both parse here
        return level_body()
    if p == "pow/bank/user/account":
        return bank_body()
    if p.startswith("pow/bank/currency/") and p.endswith("/cap/info"):
        a = ACCOUNT
        return {"currencyName": POW_CURRENCY,
                "fundsCap": (a.pow_funds_cap if a else 100000),
                "fundsEarnedInPeriod": 0}
    if p.endswith("/count") or p.endswith("/countUnread"):
        return {"count": 0, "totalCount": 0}
    # Everything list-shaped gets a terminating page. Catalogue, inventory, gifts,
    # friends, activity, messages, news -- all were captured live and all page.
    if ("/list" in p or p in ("pow/v2/activity", "pow/user/friends", "pow/message",
                              "pow/mm", "pow/communication/all", "pow/news/user",
                              "pow/nucleus/entitlements", "pow/inventory/item")):
        return _empty_page()
    return None


class _Handler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    kind = "api"

    def _handle(self):
        n = int(self.headers.get("Content-Length", 0) or 0)
        body = self.rfile.read(n) if n else b""
        tag = "" if is_known_path(self.path) else "  !! PATH NOT IN THE EXTRACTED TEMPLATE SET"
        log("%s %s %s%s" % (self.kind.upper(), self.command, self.path, tag))
        for k, v in self.headers.items():
            log("    %s: %s" % (k, v))
        if body:
            log("    body: %s" % body[:65536].decode("utf-8", "replace"))

        if self.kind == "content":
            # Art assets (.dds/.png). We have none; 404 is the honest answer and is
            # what a missing-asset CDN would return. Logged so we learn what art the
            # client wants before deciding to synthesise any.
            self.send_response(404)
            self.send_header("Content-Length", "0")
            self.end_headers()
            log("  -> 404 (no asset)")
            return

        payload = serve_body(self.path, self.command) if MODE == "serve" else None
        raw = json.dumps(payload if payload is not None else {}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(raw)
        log("  -> 200 %s" % raw[:400].decode())

    do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle

    def log_message(self, *a):
        pass


class _ContentHandler(_Handler):
    kind = "content"


def _serve(addr, handler, label):
    host, port = addr
    srv = http.server.ThreadingHTTPServer((host, port), handler)
    log("=== pow %s listening on http://%s:%d ===" % (label, host, port))
    srv.serve_forever()


if __name__ == "__main__":
    open(LOG, "a").close()
    api = _split(API_ADDR, 8094)
    content = _split(CONTENT_ADDR, 8080)
    log("=== pow_server MODE=%s api=%s:%d content=%s:%d user=%r ==="
        % (MODE, api[0], api[1], content[0], content[1], _username()))
    t = threading.Thread(target=_serve, args=(content, _ContentHandler, "content"),
                         daemon=True)
    t.start()
    _serve(api, _Handler, "api")
