#!/usr/bin/env python3
"""Switch the FIFA client's Blaze ports between PRODUCTION and STAGING, reversibly.

The staging sold experiment needs the client to talk to the staging Blaze, which is
what advertises the staging UTAS base. Two files are involved and the distinction
matters, because getting it wrong silently runs the whole experiment against
production:

  * `openfut.cfg` in the game directory is what the injected hook reads at connect
    time -- but it is a DERIVED artifact.
  * `~/.config/openfut-launcher/config.json` is the AUTHORITATIVE source. The
    launcher reconciles openfut.cfg from it, fail-closed, immediately before every
    launch. An edit to openfut.cfg alone is therefore destroyed by the next launch:
    observed live, with openfut.cfg's mtime 2s before the FIFA process start and the
    hook logging `blaze_redir=42127 blaze_main=42130` after it had been set to
    staging. So this script owns BOTH, source first.

Safety properties, in order of importance:

  * Production values are recorded to a sidecar next to each file BEFORE the first
    edit, and `restore` reads those sidecars rather than assuming what production
    was. Missing sidecar means restore refuses.
  * `restore` is idempotent and safe at any time, including after a crash.
  * Every operation re-reads both files afterwards and prints them, so the result is
    verified rather than assumed.
  * It refuses to edit while FIFA is running (the hook reads openfut.cfg at connect
    time) AND while the launcher is running (the launcher holds its config in
    memory and would write the stale values straight back over ours).
  * Only known keys are rewritten. Unknown lines and unrelated JSON keys are passed
    through untouched, and a missing key is an error rather than a silent append.

    python3 scripts/sold-client-ports.py show
    python3 scripts/sold-client-ports.py staging   # 42327 / 42330
    python3 scripts/sold-client-ports.py restore   # back to the recorded values
"""
import json
import subprocess
import sys

CLIENT = "alex@10.10.0.105"
CFG = "/mnt/games/FIFA 17/openfut.cfg"
SIDECAR = "/mnt/games/FIFA 17/openfut.cfg.openfut-prod-ports"
KEYS = ("blaze_redirector_port", "blaze_main_port")
STAGING = {"blaze_redirector_port": "42327", "blaze_main_port": "42330"}
# The launcher is the real owner; openfut.cfg is regenerated from these.
LAUNCHER_CFG = "/home/alex/.config/openfut-launcher/config.json"
LAUNCHER_SIDECAR = "/home/alex/.config/openfut-launcher/config.json.openfut-prod-ports"
LAUNCHER_KEY = {"blaze_redirector_port": "openfut_blaze_redirector_port",
                "blaze_main_port": "openfut_blaze_main_port"}


def ssh(script):
    r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT, script],
                       capture_output=True, text=True, timeout=60)
    if r.returncode != 0:
        raise SystemExit(f"ssh failed ({r.returncode}): {r.stderr.strip()}")
    return r.stdout


def read_cfg():
    return ssh(f'cat "{CFG}"')


def parse(text):
    out = {}
    for line in text.splitlines():
        if "=" in line and not line.strip().startswith("#"):
            k, _, v = line.partition("=")
            out[k.strip()] = v.strip()
    return out


def fifa_running():
    """True if a real FIFA 17 process exists on the client.

    Matches /proc/<pid>/comm exactly rather than `pgrep -f FIFA17.exe`: the pattern
    form self-matched the remote shell running it (the SSH command line contains the
    literal string), so the guard was permanently stuck ON and could never report
    "not running". comm is the executable name, so the shell reads as zsh/bash and
    only a genuine FIFA process matches. Still fail-closed: any read error or
    unexpected output is treated as "running".
    """
    out = ssh(
        "for d in /proc/[0-9]*; do "
        "[ -r \"$d/comm\" ] && [ \"$(cat $d/comm 2>/dev/null)\" = FIFA17.exe ] "
        "&& echo ${d#/proc/}; done || true"
    ).strip()
    return bool(out)


def launcher_running():
    """True if openfut-launcher is up. Its `comm` is truncated by the kernel to 15
    chars ("openfut-launche"), which has bitten this project before, so match the
    truncated form rather than the full name."""
    out = ssh(
        "for d in /proc/[0-9]*; do c=$(cat $d/comm 2>/dev/null); "
        "case \"$c\" in openfut-launche*) echo ${d#/proc/};; esac; done || true"
    ).strip()
    return bool(out)


def read_launcher():
    return ssh(f'cat "{LAUNCHER_CFG}"')


def launcher_ports(text):
    d = json.loads(text)
    return {k: str(d.get(LAUNCHER_KEY[k])) for k in KEYS}


def write_launcher(values):
    """Rewrite only the two port keys, preserving every other setting and the file's
    formatting conventions, then verify by re-reading."""
    text = read_launcher()
    d = json.loads(text)
    for k in KEYS:
        if LAUNCHER_KEY[k] not in d:
            raise SystemExit(
                f"key {LAUNCHER_KEY[k]!r} absent from {LAUNCHER_CFG}; refusing to guess")
    existing = ssh(f'cat "{LAUNCHER_SIDECAR}" 2>/dev/null || true').strip()
    if not existing:
        rec = "\n".join(f"{k}={d[LAUNCHER_KEY[k]]}" for k in KEYS)
        ssh(f'cat > "{LAUNCHER_SIDECAR}" <<\'EOF\'\n{rec}\nEOF')
        print(f"recorded launcher production ports to {LAUNCHER_SIDECAR}:\n{rec}")
    for k, v in values.items():
        d[LAUNCHER_KEY[k]] = int(v)
    body = json.dumps(d, indent=2, sort_keys=True)
    ssh(f'cat > "{LAUNCHER_CFG}" <<\'EOF\'\n{body}\nEOF')
    after = launcher_ports(read_launcher())
    for k, v in values.items():
        if after.get(k) != str(v):
            raise SystemExit(
                f"VERIFY FAILED in launcher config: {k} is {after.get(k)!r}, want {v!r}")
    print(f"--- launcher config now: {after}")


def show():
    text = read_cfg()
    print(f"--- {CFG}")
    print(text.rstrip())
    cur = parse(text)
    print("--- blaze ports:", {k: cur.get(k) for k in KEYS})
    side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
    print("--- recorded production ports:", side or "(none recorded yet)")
    print(f"--- {LAUNCHER_CFG} (authoritative)")
    print("--- launcher blaze ports:", launcher_ports(read_launcher()))
    lside = ssh(f'cat "{LAUNCHER_SIDECAR}" 2>/dev/null || true').strip()
    print("--- recorded launcher production ports:", lside or "(none recorded yet)")
    print("--- FIFA client running:", "YES" if fifa_running() else "no")
    print("--- launcher running:", "YES" if launcher_running() else "no")
    return cur


def write_ports(values, label):
    if fifa_running():
        raise SystemExit(
            "REFUSING to edit while a FIFA client is running.\n"
            "The hook reads openfut.cfg at connect time; exit FIFA first."
        )
    if launcher_running():
        raise SystemExit(
            "REFUSING to edit while openfut-launcher is running.\n"
            "The launcher holds its config in memory and reconciles openfut.cfg from\n"
            "it fail-closed before every launch, so it would write the production\n"
            "ports straight back over ours. Quit the launcher first."
        )
    # Source of truth first, then the derived file.
    write_launcher(values)
    text = read_cfg()
    cur = parse(text)
    for k in KEYS:
        if k not in cur:
            raise SystemExit(f"key {k!r} is absent from {CFG}; refusing to guess it")
    # Record production values once, before the first mutation, so restore never
    # has to assume what they were.
    existing_sidecar = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
    if not existing_sidecar and label == "staging":
        rec = "\n".join(f"{k}={cur[k]}" for k in KEYS)
        ssh(f'cat > "{SIDECAR}" <<\'EOF\'\n{rec}\nEOF')
        print(f"recorded production ports to {SIDECAR}:\n{rec}")

    out = []
    for line in text.splitlines():
        k = line.partition("=")[0].strip()
        out.append(f"{k}={values[k]}" if k in values else line)
    body = "\n".join(out) + "\n"
    ssh(f'cat > "{CFG}" <<\'EOF\'\n{body.rstrip()}\nEOF')
    after = parse(read_cfg())
    for k, v in values.items():
        if after.get(k) != v:
            raise SystemExit(f"VERIFY FAILED: {k} is {after.get(k)!r}, expected {v!r}")
    print(f"--- now pointing at {label}")
    print(read_cfg().rstrip())


def main():
    if len(sys.argv) != 2 or sys.argv[1] not in ("show", "staging", "restore"):
        print(__doc__)
        return 2
    cmd = sys.argv[1]
    if cmd == "show":
        show()
        return 0
    if cmd == "staging":
        write_ports(STAGING, "staging")
        return 0
    side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
    if not side:
        raise SystemExit(
            f"no recorded production ports at {SIDECAR}; refusing to guess.\n"
            "Set them by hand and verify with `show`."
        )
    write_ports(parse(side), "production (restored)")
    return 0


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