#!/usr/bin/env python3
"""Scrape SAND's master server by replaying its protocol (2026-06-15 build) from an external
process - no game, no BattlEye interaction (like playfab_scrape.py).

HANDSHAKE (RE'd from ghidra decompile of GameAssembly.dll + live Player.log; build 23737037):
  TWO sequential WebSocket connections, both speaking ClientMessage JSON envelopes:
    envelope = {"Id":<long>, "ClientMessageType":<int>, "Action":<int>, "Message":<string|null>}
      ClientMessageType: Error=0, Ping=1, Action=2, Event=3   (requests use 2)
      Action (ClientAction): Login=0, Connect=1, GetCharacters=2, GetStorage=20, GetNews=21,
        GetCompartmentDefinitions=23, GetDatabaseGuid=26, GetDefaultWalkerBlueprints=28,
        GetServers=29, GetResearchTree=32, GetShopItems=39, ...
    `Message` for Login is the LoginRequest serialized as a JSON *string* (nested); for Connect it
    is the bool isDebug stringified ("False"); for parameterless gets it is null.
  Serialization: Newtonsoft default -> PascalCase fields, enums as integers, sent as TEXT frames.
  Replies: a ClientMessage with the same Id; its `Message` is JSON of OperationResult<T> =
    {"IsSucceed":bool,"Error":string|null,"Status":int|null,"Result":<T>}. Correlate by Id.

  Step 1  /login  (no auth header): connect wss://<region>.hologryph.com/gameclient/login,
          send ClientMessage{Action=Login(0), Message=JSON(LoginRequest{SessionTicket=<PlayFab>,
          Title=56693, Platform=STEAM(1), ClientVersion=<git hash>})}; reply Result.SessionTicket
          is the SERVER session ticket. Close.
  Step 2  /connect (persistent): connect wss://<region>.hologryph.com/gameclient/connect WITH
          HTTP upgrade header  Authorization: <server SessionTicket>  ; send Connect(Action=1,
          Message="False"); then all data ops over this same socket.

SAFETY: does nothing over the network without --go. Token from reverse/.secrets/playfab_token.json.

Usage:
  venv/bin/python reverse/master_scrape.py --selftest      # offline
  venv/bin/python reverse/master_scrape.py                 # dry run (plan only)
  venv/bin/python reverse/master_scrape.py --go --data     # ARMED: full handshake + data ops
"""
import asyncio, json, os, argparse, ssl, sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRETS = os.path.join(ROOT, "reverse", ".secrets", "playfab_token.json")
NAMES_PATH = os.path.join(ROOT, "extracted", "item_names.json")
OUTDIR = os.path.join(ROOT, "extracted")

REGION_BASES = {r: "wss://%s.hologryph.com/gameclient/" % r
                for r in ("ger", "eus", "westus", "sin", "bra", "aus", "sko", "uae")}
MT_ACTION = 2
ACTIONS = {"Login": 0, "Connect": 1, "GetCharacters": 2, "SelectCharacter": 4, "GetTrampler": 16,
           "UpsertTrampler": 17, "GetStorage": 20, "GetNews": 21, "GetCompartmentDefinitions": 23,
           "GetDatabaseGuid": 26, "GetDefaultWalkerBlueprints": 28, "GetServers": 29,
           "GetResearchTree": 32, "GetShopItems": 39}
PLATFORM_STEAM = 1
DATA_OPS = ["GetCompartmentDefinitions", "GetShopItems", "GetResearchTree", "GetServers",
            "GetDefaultWalkerBlueprints", "GetNews", "GetDatabaseGuid"]
USER_OPS = ["GetStorage", "GetCharacters"]


def load_names():
    try:
        d = json.load(open(NAMES_PATH))["items"]
        return {k: (v.get("name") or k) for k, v in d.items()}
    except Exception:
        return {}


def load_token():
    try:
        return json.load(open(SECRETS))
    except Exception as e:
        return {"_error": str(e)}


def mask(s):
    return (s[:6] + "..." + s[-4:]) if s and len(s) > 12 else "<none>"


def login_request(tok, version):
    return {"SessionTicket": tok["SessionTicket"], "Title": tok.get("TitleId", "56693"),
            "Platform": PLATFORM_STEAM, "ClientVersion": version}


class WS:
    """One ClientMessage WebSocket: connect (opt. Authorization header) -> id-correlated requests."""
    def __init__(self, url, auth=None, insecure=False):
        self.url = url
        self.auth = auth
        self.ssl = ssl.create_default_context()
        if insecure:
            self.ssl.check_hostname = False
            self.ssl.verify_mode = ssl.CERT_NONE
        self._id = 0
        self._pending = {}
        self.events = []
        self.ws = None

    async def connect(self):
        import websockets
        headers = {"Authorization": self.auth} if self.auth else None
        self.ws = await websockets.connect(self.url, ssl=self.ssl, open_timeout=15,
                                           max_size=None, ping_interval=None,
                                           additional_headers=headers)
        self._task = asyncio.create_task(self._recv())

    async def _recv(self):
        try:
            async for raw in self.ws:
                if isinstance(raw, bytes):
                    raw = raw.decode("utf-8", "replace")
                try:
                    msg = json.loads(raw)
                except Exception:
                    self.events.append({"_unparsed": raw[:300]}); continue
                if msg.get("ClientMessageType") == 1:  # Ping -> echo
                    try:
                        await self.ws.send(json.dumps(msg))
                    except Exception:
                        pass
                    continue
                rid = msg.get("Id")
                if rid in self._pending and not self._pending[rid].done():
                    self._pending[rid].set_result(msg)
                else:
                    self.events.append(msg)
        except Exception as e:
            for f in self._pending.values():
                if not f.done():
                    f.set_exception(e)

    async def request(self, action_name, payload=None, raw_message=None, timeout=30):
        self._id += 1
        rid = self._id
        if raw_message is not None:
            message = raw_message
        elif payload is not None:
            message = json.dumps(payload)
        else:
            message = None
        fut = asyncio.get_event_loop().create_future()
        self._pending[rid] = fut
        await self.ws.send(json.dumps({"Id": rid, "ClientMessageType": MT_ACTION,
                                       "Action": ACTIONS[action_name], "Message": message}))
        reply = await asyncio.wait_for(fut, timeout=timeout)
        inner = reply.get("Message")
        if not isinstance(inner, str) or not inner:
            return inner
        try:
            return json.loads(inner)
        except ValueError:
            return inner  # non-JSON Message (e.g. plain string / empty result)

    async def close(self):
        if self.ws:
            await self.ws.close()


def resolve(obj, names):
    if isinstance(obj, dict):
        out = {}
        for k, v in obj.items():
            out[k] = resolve(v, names)
            if k in ("DefinitionName", "ItemDefinition", "EpbId") and isinstance(v, str) and v in names:
                out["_name"] = names[v]
        return out
    if isinstance(obj, list):
        return [resolve(x, names) for x in obj]
    return obj


def unwrap(msg):
    """Replies are either the DTO directly, or an OperationResult{IsSucceed,Result}. Normalize."""
    if isinstance(msg, dict) and "IsSucceed" in msg and "Result" in msg:
        return msg["Result"]
    return msg


def save(op_name, result, names):
    out = os.path.join(OUTDIR, "master_%s.json" % op_name)
    json.dump({"_op": op_name, "Result": resolve(result, names)},
              open(out, "w"), indent=1, ensure_ascii=False, default=str)
    print("    wrote %s (%s)" % (out, len(result) if isinstance(result, list) else 1))


async def run(args):
    base = REGION_BASES[args.region]
    names = load_names()
    tok = load_token()
    version = args.client_version or tok.get("ClientVersion", "")

    # --- Step 1: /login (no header) -> server SessionTicket ---
    print("STEP 1  /login  %slogin" % base)
    lg = WS(base + "login", insecure=args.insecure)
    await lg.connect()
    res = await lg.request("Login", payload=login_request(tok, version))   # Message = LoginUserResultDto
    await lg.close()
    res = unwrap(res)
    srv_ticket = res.get("SessionTicket") if isinstance(res, dict) else None
    if not srv_ticket:
        print("  login failed; reply:", json.dumps(res)[:300]); return
    user = res.get("User", {})
    print("  Login OK. User.Id=%s name=%s -> server ticket %s" %
          (user.get("Id"), user.get("DisplayName"), mask(srv_ticket)))
    save("Login", res, names)

    # --- Step 2: /connect (Authorization header) -> persistent socket ---
    print("STEP 2  /connect  %sconnect  (Authorization: <server ticket>)" % base)
    cx = WS(base + "connect", auth=srv_ticket, insecure=args.insecure)
    await cx.connect()
    cop = await cx.request("Connect", raw_message="False")
    print("  Connect ->", json.dumps(cop)[:160] if cop is not None else "(no body)")

    ops = (DATA_OPS if args.data or not args.user else []) + (USER_OPS if args.user else [])
    for name in ops:
        try:
            res = unwrap(await cx.request(name))
            n = len(res) if isinstance(res, list) else (1 if res else 0)
            print("  %-28s -> %s entr%s" % (name, n, "y" if n == 1 else "ies"))
            if res is not None:
                save(name, res, names)
        except Exception as e:
            print("  %-28s ERROR %s: %s" % (name, type(e).__name__, e))
    await cx.close()
    print("done. events: %d" % len(cx.events))


def print_plan(args):
    base = REGION_BASES[args.region]
    tok = load_token()
    ver = args.client_version or tok.get("ClientVersion", "<VERSION>")
    print("=== DRY RUN (no network). Pass --go to connect. ===")
    print("STEP 1  connect %slogin   (no auth header)" % base)
    lr = login_request({"SessionTicket": "<PLAYFAB_TICKET>", "TitleId": tok.get("TitleId", "56693")}, ver)
    print("  send {\"Id\":1,\"ClientMessageType\":2,\"Action\":0,\"Message\":%s}" % json.dumps(json.dumps(lr)))
    print("  (real PlayFab ticket %s) -> reply Result.SessionTicket = server ticket" %
          mask(tok.get("SessionTicket", "")))
    print("STEP 2  connect %sconnect  HEADER Authorization: <server ticket>" % base)
    print("  send {\"Id\":1,\"ClientMessageType\":2,\"Action\":1,\"Message\":\"False\"}")
    for i, name in enumerate(DATA_OPS, 2):
        print("  then {\"Id\":%d,\"ClientMessageType\":2,\"Action\":%d,\"Message\":null}  (%s)"
              % (i, ACTIONS[name], name))
    print("\nNothing sent.")


def selftest():
    lr = login_request({"SessionTicket": "T", "TitleId": "56693"}, "abc")
    assert lr == {"SessionTicket": "T", "Title": "56693", "Platform": 1, "ClientVersion": "abc"}, lr
    assert ACTIONS["Login"] == 0 and ACTIONS["Connect"] == 1 and ACTIONS["GetCompartmentDefinitions"] == 23
    names = {"item_coinCrown": "Crowns"}
    assert resolve({"ItemDefinition": "item_coinCrown"}, names)["_name"] == "Crowns"
    print("selftest OK: login payload, actions, name resolution")


def main():
    ap = argparse.ArgumentParser(description="SAND master-server replay (two-socket ClientMessage)")
    ap.add_argument("--region", default="ger", choices=list(REGION_BASES))
    ap.add_argument("--go", action="store_true")
    ap.add_argument("--data", action="store_true")
    ap.add_argument("--user", action="store_true")
    ap.add_argument("--client-version", default="")
    ap.add_argument("--insecure", action="store_true")
    ap.add_argument("--selftest", action="store_true")
    args = ap.parse_args()
    if args.selftest:
        selftest(); return
    if not args.go:
        print_plan(args); return
    asyncio.run(run(args))


if __name__ == "__main__":
    main()
