#!/usr/bin/env python3
"""Wire-level verification of the seller-facing SOLD flow, in isolation.

Proves the harness produces a correct, authentic sold row BEFORE any operator time
is spent driving a real FIFA client. Brings up its own Core + utas-host on ephemeral
ports against throwaway databases, runs the real settlement through the
`staging_sell` binary, then reads every seller-facing surface under BOTH A/B
variants and exercises the bulk clear verb.

ISOLATION: production ports 8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216,
8080, 8081 and 8094 are in a hard deny-list checked before every bind and every
request, and nothing under /home/alex/openfut-promotion/state/ is opened.

    python3 scripts/sold-wire-check.py [--keep]
"""
import argparse
import http.client
import json
import os
import shutil
import socket
import sqlite3
import subprocess
import sys
import tempfile
import time

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
TS = "2026-01-01T00:00:00Z"
PERSONA = "33068179"
SELLER_CLUB = "club-seller-a"
BUYER_CLUB = "club-buyer-b"
ITEM = "core-disposable-x"
CARD = "def-disposable"
TRADE_ID = "900500150"
GROSS = 150

checks = []


def check(label, ok, detail=""):
    checks.append((label, bool(ok), detail))
    print(f"  [{'PASS' if ok else 'FAIL'}] {label}{(': ' + detail) if detail else ''}")
    return ok


def free_port():
    for _ in range(200):
        s = socket.socket()
        s.bind(("127.0.0.1", 0))
        p = s.getsockname()[1]
        s.close()
        if p not in FORBIDDEN and p > 1024:
            return p
    raise RuntimeError("no free port")


def req(port, method, path, body=None):
    assert port not in FORBIDDEN, f"refusing to contact production port {port}"
    c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
    headers = {"X-OpenFUT-Game": "fifa17"}
    if body is not None:
        headers["Content-Type"] = "application/json"
    c.request(method, path, body=json.dumps(body) if body is not None else None,
              headers=headers)
    r = c.getresponse()
    raw = r.read()
    c.close()
    try:
        return r.status, json.loads(raw)
    except Exception:
        return r.status, raw.decode("utf-8", "replace")


def wait_http(port, path, timeout=45, proc=None, log=None):
    deadline = time.time() + timeout
    while time.time() < deadline:
        if proc is not None and proc.poll() is not None:
            tail = ""
            if log and os.path.exists(log):
                tail = open(log).read()[-1500:]
            raise RuntimeError(f"process exited {proc.returncode}\n{tail}")
        try:
            st, _ = req(port, "GET", path)
            if st < 500:
                return
        except Exception:
            time.sleep(0.25)
    tail = open(log).read()[-1500:] if log and os.path.exists(log) else ""
    raise RuntimeError(f"{path} on {port} never became ready\n{tail}")


def seed(db):
    """Two identities by direct SQL: Seller A (the FIFA persona) and synthetic Buyer B."""
    con = sqlite3.connect(db)
    for prof, club, coins, game in (
        ("prof-seller-a", SELLER_CLUB, 1_000, "fifa17"),
        ("prof-buyer-b", BUYER_CLUB, 20_000, "buyer-game"),
    ):
        con.execute(
            "INSERT INTO profiles (id, username, game_id, created_at, updated_at) "
            "VALUES (?, ?, ?, ?, ?)", (prof, prof, game, TS, TS))
        con.execute(
            "INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) "
            "VALUES (?, ?, ?, ?, ?, ?)", (club, prof, club, coins, TS, TS))
    con.execute(
        "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) "
        "VALUES (?, ?, ?, 0, ?)", (ITEM, SELLER_CLUB, CARD, TS))
    con.commit()
    con.close()


def owner_of(db, item):
    con = sqlite3.connect(db)
    row = con.execute("SELECT club_id FROM owned_cards WHERE id = ?", (item,)).fetchone()
    n = con.execute("SELECT COUNT(*) FROM owned_cards WHERE id = ?", (item,)).fetchone()[0]
    coins = dict(con.execute("SELECT id, coins FROM clubs").fetchall())
    con.close()
    return (row[0] if row else None), n, coins


def start_core(tmp, port, log):
    db = os.path.join(tmp, "core.db")
    pack = os.path.join(tmp, "pack.json")
    with open(pack, "w") as f:
        # A top-level ARRAY: Core's content-pack loader expects a sequence, not a
        # map. Needed because the preflight refuses to start when an owned card
        # references a CardDefinitionId no pack defines.
        json.dump([{
            "id": CARD, "name": "Disposable", "overall": 75, "position": "ST",
            "nation": "Nation", "league": "League", "club": "Club",
            "pace": 75, "shooting": 75, "passing": 75, "dribbling": 75,
            "defending": 40, "physical": 70, "rarity": "gold",
            "image_path": None,
        }], f)
    env = dict(os.environ,
               LISTEN_ADDR=f"127.0.0.1:{port}",
               DATABASE_URL=f"sqlite://{db}",
               # Core's real data dir (read-only): it needs chemistry_styles.json
               # and friends. The throwaway DB and the content pack stay in tmp.
               DATA_DIR=os.path.join(REPO, "openfut-core", "data"),
               OPENFUT_CONTENT_PACKS=pack)
    # Migrate-only pass first: Core owns its schema, so the fixture cannot be
    # seeded into an empty file. Stop it before the external writer touches the db.
    with open(log, "w") as lf:
        p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
                             cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
    wait_http(port, "/health", proc=p, log=log)
    p.terminate()
    p.wait(timeout=20)
    seed(db)
    with open(log, "a") as lf:
        p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
                             cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
    wait_http(port, "/health", proc=p, log=log)
    return p, db


def start_host(tmp, port, core_port, log, variant, coins_processed="0",
               count_mode="active"):
    with open(os.path.join(tmp, "catalog.json"), "w") as f:
        # Minimal STAGING catalog. Deliberately NOT the production catalog, which
        # lives under /home/alex/openfut-promotion/state/ and must never be opened.
        json.dump({"schema_version": 1, "game": "fifa17",
                   "cards": {CARD: {"asset_id": 212188, "version": 0,
                                    "rareflag": 1, "kind": "player"}}}, f)
    env = dict(os.environ,
               OPENFUT_UTAS_HOST_ADDR=f"127.0.0.1:{port}",
               OPENFUT_CORE_URL=f"http://127.0.0.1:{core_port}",
               # Deliberately dead: any Python fallback must fail closed and be
               # visible, never silently serve production data.
               OPENFUT_UTAS_PYTHON_URL="http://127.0.0.1:9",
               OPENFUT_FIFA17_TABLES_DIR=os.path.join(REPO, "fifa17-recon/data/tables"),
               OPENFUT_IDENTITY_STORE=os.path.join(tmp, "identity.json"),
               OPENFUT_PERSONA_ID=PERSONA,
               OPENFUT_MARKET_DB=os.path.join(tmp, "market.db"),
               OPENFUT_PILE_DB=os.path.join(tmp, "pile.db"),
               OPENFUT_FIFA17_SOLD_EXPERIMENT=variant,
               OPENFUT_FIFA17_SOLD_COINS_PROCESSED=coins_processed,
               OPENFUT_FIFA17_SOLD_COUNT_MODE=count_mode,
               OPENFUT_FIFA17_CATALOG=os.path.join(tmp, "catalog.json"),
               RUST_LOG="info")
    with open(log, "w") as lf:
        p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-utas-host")],
                             cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
    wait_http(port, "/ut/game/fifa17/tradePile/counts", proc=p, log=log)
    return p


def banner(t):
    print("\n" + "=" * 72)
    print(f"== {t}")
    print("=" * 72)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--keep", action="store_true")
    args = ap.parse_args()

    tmp = tempfile.mkdtemp(prefix="openfut-sold-wire-")
    procs = []
    try:
        core_port, host_port = free_port(), free_port()
        core_log = os.path.join(tmp, "core.log")
        host_log = os.path.join(tmp, "host.log")
        banner("ISOLATED STAGING (production untouched)")
        print(f"  tmp        : {tmp}")
        print(f"  core       : 127.0.0.1:{core_port}")
        print(f"  utas-host  : 127.0.0.1:{host_port}")
        print(f"  forbidden  : {sorted(FORBIDDEN)}")

        core, core_db = start_core(tmp, core_port, core_log)
        procs.append(core)
        host = start_host(tmp, host_port, core_port, host_log, "highest")
        procs.append(host)
        print("  both ready")

        bann = [l for l in open(host_log) if "sold-experiment" in l]
        check("host banner names the variant", any("bidState=highest" in l for l in bann),
              (bann[0].strip() if bann else "no banner"))

        banner("BEFORE — seller A owns the item, nothing listed")
        own, n, coins = owner_of(core_db, ITEM)
        print(f"  owner={own} instances={n} coins={coins}")
        st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
        st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
        print(f"  /tradePile total={pile.get('total')}  counts={json.dumps(counts)}")
        check("seller owns the item", own == SELLER_CLUB, str(own))
        check("no rows before listing", pile.get("total") == 0)
        check("sold counter starts at 0", counts.get("sold") == 0)

        banner(f"LIST — authentic active listing at {GROSS} coins")
        # Seed the listing directly into the staging market db: the client normally
        # does this via POST /auctionhouse, which needs a wire-id mapping we do not
        # have in this headless check. The LISTING SHAPE is identical either way.
        mdb = os.path.join(tmp, "market.db")
        con = sqlite3.connect(mdb)
        con.execute(
            "INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
            "wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
            "item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
            (TRADE_ID, CARD, ITEM, 100000178, 212188, GROSS, GROSS, "CAGE",
             str(int(time.time() * 1000)), json.dumps({
                 "id": 100000178, "resourceId": 212188, "rating": 75,
                 "preferredPosition": "ST", "itemState": "forSale",
                 "untradeable": False, "assetId": 212188}), 3600))
        con.commit()
        con.close()
        st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
        st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
        row = pile["auctionInfo"][0]
        print(f"  active row: tradeState={row['tradeState']} bidState={row['bidState']} "
              f"expires={row['expires']} counts={json.dumps(counts)}")
        check("active row is active/none", row["tradeState"] == "active" and row["bidState"] == "none")
        check("counts.selling == 1 while active", counts.get("selling") == 1)
        check("counts.sold still 0 while active", counts.get("sold") == 0)

        banner("PURCHASE — synthetic Buyer B, through the REAL settlement path")
        out = subprocess.run(
            [os.path.join(REPO, "target/release/staging_sell"),
             "--market-db", mdb, "--core-url", f"http://127.0.0.1:{core_port}",
             "--trade-id", TRADE_ID, "--item", ITEM,
             "--seller", SELLER_CLUB, "--buyer", BUYER_CLUB, "--gross", str(GROSS)],
            capture_output=True, text=True, timeout=120)
        print("  " + "\n  ".join((out.stdout + out.stderr).strip().splitlines()))
        check("staging_sell succeeded", out.returncode == 0, f"exit {out.returncode}")

        own, n, coins = owner_of(core_db, ITEM)
        fee = GROSS * 5 // 100
        print(f"  owner={own} instances={n} coins={coins} fee={fee}")
        check("ownership transferred to buyer", own == BUYER_CLUB, str(own))
        check("exactly ONE authoritative instance", n == 1, str(n))
        check("buyer debited gross", coins.get(BUYER_CLUB) == 20_000 - GROSS,
              str(coins.get(BUYER_CLUB)))
        check("seller credited net", coins.get(SELLER_CLUB) == 1_000 + GROSS - fee,
              str(coins.get(SELLER_CLUB)))
        check("economy shrank by exactly the fee",
              21_000 - sum(coins.values()) == fee, str(21_000 - sum(coins.values())))

        banner("SOLD ROW — variant A: closed / highest")
        st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
        st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
        st3, status = req(host_port, "GET", f"/ut/game/fifa17/trade/status?tradeIds={TRADE_ID}")
        a_row = pile["auctionInfo"][0] if pile.get("auctionInfo") else {}
        print("  " + json.dumps(a_row, indent=2).replace("\n", "\n  "))
        print(f"  counts={json.dumps(counts)}")
        check("sold row is present in the pile", pile.get("total") == 1)
        check("tradeState closed", a_row.get("tradeState") == "closed")
        check("bidState highest (variant A)", a_row.get("bidState") == "highest")
        check("currentBid == sale price", a_row.get("currentBid") == GROSS)
        check("expires 0", a_row.get("expires") == 0)
        check("twelve atoms exactly", len(a_row) == 12, str(len(a_row)))
        check("counts.sold == 1", counts.get("sold") == 1)
        check("counts.selling == 0", counts.get("selling") == 0)
        check("/trade/status agrees", status["auctionInfo"][0]["tradeState"] == "closed"
              and status["auctionInfo"][0]["bidState"] == "highest")

        banner("VARIANT B — same state, restart host with closed / buyNow")
        host.terminate(); host.wait(timeout=20); procs.remove(host)
        # coinsProcessed is held CONSTANT at 0 here: the primary A/B must be
        # attributable to bidState alone. The orthogonal coinsProcessed test is a
        # separate variant pair (see scripts/sold-ab-differential.py).
        host = start_host(tmp, host_port, core_port, host_log, "buyNow",
                          coins_processed="0", count_mode="active_plus_sold")
        procs.append(host)
        st, pileb = req(host_port, "GET", "/ut/game/fifa17/tradePile")
        st2, countsb = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
        b_row = pileb["auctionInfo"][0]
        print(f"  bidState={b_row['bidState']} coinsProcessed={b_row['coinsProcessed']} "
              f"counts={json.dumps(countsb)}")
        check("bidState buyNow (variant B)", b_row.get("bidState") == "buyNow")
        check("coinsProcessed held constant at 0", b_row.get("coinsProcessed") == 0)
        check("count_mode active_plus_sold counts the sold row",
              countsb.get("count") == 1 and countsb.get("sold") == 1,
              json.dumps(countsb))
        differing = sorted(k for k in a_row if a_row.get(k) != b_row.get(k))
        check("A/B differ ONLY in bidState (one-field attribution)",
              differing == ["bidState"], str(differing))

        banner("CLEAR — the PE-proven bulk verb DELETE .../trade/sold")
        pre_coins = owner_of(core_db, ITEM)[2]
        st, body = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
        print(f"  HTTP {st} body={json.dumps(body)}")
        st2, pilec = req(host_port, "GET", "/ut/game/fifa17/tradePile")
        st3, countsc = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
        own2, n2, post_coins = owner_of(core_db, ITEM)
        print(f"  after clear: total={pilec.get('total')} counts={json.dumps(countsc)} "
              f"owner={own2} instances={n2}")
        check("clear acks 200 {}", st == 200 and body == {})
        check("sold row gone from the pile", pilec.get("total") == 0)
        check("counts.sold back to 0", countsc.get("sold") == 0)
        check("clear moved NO coins", pre_coins == post_coins, f"{pre_coins} -> {post_coins}")
        check("buyer still owns the item after clear", own2 == BUYER_CLUB, str(own2))
        check("still exactly one instance", n2 == 1, str(n2))
        st, again = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
        check("clearing again is a safe no-op", st == 200)
        cleared = [l for l in open(host_log) if "market-clear-sold" in l]
        check("clear is logged for capture", bool(cleared),
              cleared[-1].strip() if cleared else "no log line")

        banner("PRODUCTION UNTOUCHED")
        alive = subprocess.run(["ps", "-o", "pid=", "-p", "3631953"],
                               capture_output=True, text=True).stdout.strip()
        check("prod-host pid 3631953 still alive", alive == "3631953", alive or "gone")
        opened = subprocess.run(
            ["bash", "-lc",
             "ls -l /proc/*/fd 2>/dev/null | grep -c openfut-promotion || true"],
            capture_output=True, text=True).stdout.strip()
        print(f"  staging fds referencing production state: (informational) {opened}")

        banner("RESULT")
        passed = sum(1 for _, ok, _ in checks if ok)
        print(f"  {passed}/{len(checks)} checks passed")
        failed = [l for l, ok, _ in checks if not ok]
        if failed:
            print("  FAILED: " + "; ".join(failed))
        print("\n  " + ("ALL CHECKS PASSED" if not failed else "FAILURES PRESENT"))
        return 0 if not failed else 1
    finally:
        for p in procs:
            try:
                p.terminate(); p.wait(timeout=15)
            except Exception:
                try:
                    p.kill()
                except Exception:
                    pass
        if args.keep:
            print(f"\n  kept {tmp}")
        else:
            shutil.rmtree(tmp, ignore_errors=True)
            print(f"\n  removed {tmp}")


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