#!/usr/bin/env python3
"""LagZero server agent: measures a self-hosted game world (tick rate, query response, overruns, CPU, memory)
and asks the LagZero engine for the readout and fixes. Python 3.8+, standard library only.

  python3 lagzero_agent.py --game minecraft --rcon 127.0.0.1:25575 --rcon-pass SECRET --log logs/latest.log
  python3 lagzero_agent.py --game fivem --http 127.0.0.1:30120
  python3 lagzero_agent.py --game valheim --tcp 127.0.0.1:2456 --rate-cmd "..."

Free: up to 15 samples per readout. World Watch pass (from lagzero.delithiuslabs.ai/servers): up to 1440 (24 h at 1/min),
pass token in --pass or LAGZERO_PASS. Nothing but the numbers below is sent; no server address, password or log text.
"""
import hashlib
import argparse, json, os, re, socket, struct, subprocess, sys, time, urllib.request

API = os.environ.get("LAGZERO_API", "https://lagzero.delithiuslabs.ai/api/server")
VERSION = "lagzero-agent-1.0.0"

# ---------- Source RCON (Minecraft, ARK, Palworld, CS-style; Rust with rcon.web 0) ----------
class Rcon:
    def __init__(self, host, port, password, timeout=5.0):
        self.s = socket.create_connection((host, port), timeout=timeout); self.id = 0
        if self._req(3, password)[0] == -1: raise PermissionError("RCON password rejected")
    def _send(self, kind, body):
        self.id += 1; data = struct.pack("<ii", self.id, kind) + body.encode("utf-8") + b"\x00\x00"
        self.s.sendall(struct.pack("<i", len(data)) + data); return self.id
    def _recv(self):
        n = struct.unpack("<i", self._exact(4))[0]; d = self._exact(n)
        rid, kind = struct.unpack("<ii", d[:8]); return rid, kind, d[8:-2].decode("utf-8", "replace")
    def _exact(self, n):
        b = b""
        while len(b) < n:
            c = self.s.recv(n - len(b))
            if not c: raise ConnectionError("RCON closed")
            b += c
        return b
    def _req(self, kind, body):
        self._send(kind, body)
        while True:
            rid, k, txt = self._recv()
            if kind == 3 and k == 2: return rid, txt
            if kind != 3: return rid, txt
    def cmd(self, c): return self._req(2, c)[1]
    def close(self): self.s.close()

_COLOR = re.compile(r"§.")
def parse_tps(text):
    """Paper/Spigot 'tps' -> first (1-minute) TPS; also 'TPS: 19.8' styles and Forge mean tick time."""
    t = _COLOR.sub("", text or "")
    m = re.search(r"TPS from last[^:]*:\s*\*?([\d.]+)", t)
    if m: return min(20.0, float(m.group(1)))
    m = re.search(r"Overall\s*:\s*Mean tick time:\s*([\d.]+)\s*ms", t)
    if m: return round(min(20.0, 1000.0 / max(50.0, float(m.group(1)))), 2)
    m = re.search(r"\bTPS[:=\s]+\*?([\d.]+)", t, re.I)
    return float(m.group(1)) if m else None

def parse_fps(text):
    """Rust 'status'/'serverinfo' Framerate, or any 'fps: N' line."""
    m = re.search(r'"?Framerate"?\s*[:=]\s*([\d.]+)', text or "") or re.search(r"\bfps\b[:=\s]+([\d.]+)", text or "", re.I)
    return float(m.group(1)) if m else None

# ---------- probes ----------
def http_probe(hostport, path="/info.json", timeout=5.0):
    t0 = time.perf_counter()
    with urllib.request.urlopen(f"http://{hostport}{path}", timeout=timeout) as r: body = r.read()
    return round((time.perf_counter() - t0) * 1000, 1), body

def tcp_probe(hostport, timeout=3.0):
    h, p = hostport.rsplit(":", 1); t0 = time.perf_counter()
    with socket.create_connection((h, int(p)), timeout=timeout): pass
    return round((time.perf_counter() - t0) * 1000, 1)

OVERRUN = {
    "minecraft": re.compile(r"Can't keep up! Is the server overloaded\?"),
    "fivem": re.compile(r"server thread hitch warning", re.I),
    "rust": re.compile(r"(took \d+(\.\d+)?ms|hitch)", re.I),
    "other": re.compile(r"(can't keep up|overloaded|hitch|tick took)", re.I),
}
def count_overruns(game, lines):
    rx = OVERRUN.get(game, OVERRUN["other"]); return sum(1 for l in lines if rx.search(l))

class LogTail:
    def __init__(self, path): self.path = path; self.pos = os.path.getsize(path) if path and os.path.exists(path) else 0
    def new_lines(self):
        if not self.path or not os.path.exists(self.path): return []
        size = os.path.getsize(self.path)
        if size < self.pos: self.pos = 0  # rotated
        with open(self.path, "r", errors="replace") as f: f.seek(self.pos); data = f.read(); self.pos = f.tell()
        return data.splitlines()

def host_load(proc="/proc"):
    try:
        def cpu():
            v = list(map(int, open(f"{proc}/stat").readline().split()[1:8])); return sum(v), v[3] + v[4]
        a = cpu(); time.sleep(0.2); b = cpu(); tot = b[0] - a[0]
        cpu_pct = round(100 * (1 - (b[1] - a[1]) / tot), 1) if tot else None
        mi = {l.split(":")[0]: int(l.split()[1]) for l in open(f"{proc}/meminfo")}
        mem_pct = round(100 * (1 - mi["MemAvailable"] / mi["MemTotal"]), 1)
        return cpu_pct, mem_pct
    except Exception:
        return None, None

# ---------- collect + readout ----------
def build_payload(game, rate, resp, overruns, cpu, mem, players, token=None):
    b = {"game": game, "rate": rate, "responseMs": resp, "behindEvents": overruns, "agent": VERSION}
    if cpu is not None: b["cpuPct"] = cpu
    if mem is not None: b["memPct"] = mem
    if players is not None: b["players"] = players
    if token: b["pass"] = token
    return b

def readout(payload, api=API, timeout=15):
    req = urllib.request.Request(api, data=json.dumps(payload).encode(), headers={"content-type": "application/json", "user-agent": VERSION}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r: return json.load(r)
    except urllib.error.HTTPError as e:
        try: return json.load(e)
        except Exception: return {"ok": False, "error": f"HTTP {e.code}"}

# ---- DELITHIUS SPINE seat (2026-10-12): Truth Box + AA CoFix + Forever Learning, embedded (no installs).
# Receipts stay on your machine (~/.lagzero/truthbox_receipts.jsonl), numbers and status only, sha256-chained
# like Corey's AACoFixLedger. AA CoFix Fixer_01: retry with backoff when the engine is unreachable or 5xx.
_readout_once = readout
_SPINE_DIR = os.path.join(os.path.expanduser("~"), ".lagzero")

def _truthbox(kind, raw, **extra):
    try:
        os.makedirs(_SPINE_DIR, exist_ok=True); path = os.path.join(_SPINE_DIR, "truthbox_receipts.jsonl"); prev = "0" * 64
        if os.path.exists(path):
            with open(path) as f:
                for line in f: prev = json.loads(line).get("chain_hash", prev)
        rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "kind": kind, "source": "lagzero-agent", "raw": raw, **extra}
        rec["chain_hash"] = hashlib.sha256(f"{prev}:{time.time_ns()}:{kind}:{json.dumps(rec, sort_keys=True)}".encode()).hexdigest()
        with open(path, "a") as f: f.write(json.dumps(rec) + "\n")
    except Exception: pass

def _engine_failed(res):
    e = str(res.get("error", "")) if isinstance(res, dict) else "bad-parse"
    return (not isinstance(res, dict)) or e.startswith("HTTP 5") or "timed out" in e or "URLError" in e

def readout(payload, api=API, timeout=15):
    try: res = _readout_once(payload, api, timeout)
    except Exception as e: res = {"ok": False, "error": f"{type(e).__name__}"}
    _truthbox("reading", {"ok": res.get("ok") if isinstance(res, dict) else None, "error": res.get("error") if isinstance(res, dict) else None}, samples=len(payload.get("samples", [])) if isinstance(payload, dict) else None)
    if not _engine_failed(res): return res
    for i, wait in enumerate((2, 6, 18)):
        time.sleep(wait)
        try: res2 = _readout_once(payload, api, timeout)
        except Exception as e: res2 = {"ok": False, "error": f"{type(e).__name__}"}
        fixed = not _engine_failed(res2)
        _truthbox("AA CoFix", {"error": res2.get("error") if isinstance(res2, dict) else None}, fixer="AA CoFix", slot="Fixer_01", repair="retry-backoff", how=f"retry #{i+1} after {wait}s", fixed=fixed)
        if fixed: return res2
    _truthbox("unfixable", {"error": res.get("error") if isinstance(res, dict) else None}, fixer="AA CoFix")
    return res

def sample_once(a, rcon, tail):
    rate = resp = None; players = None
    if rcon:
        t0 = time.perf_counter(); txt = rcon.cmd(a.rate_cmd or ("tps" if a.game == "minecraft" else "serverinfo" if a.game == "rust" else "status"))
        resp = round((time.perf_counter() - t0) * 1000, 1); rate = parse_tps(txt) if a.game == "minecraft" else parse_fps(txt)
    elif a.http:
        resp, body = http_probe(a.http, "/info.json")
        try: players = len(json.loads(http_probe(a.http, "/players.json")[1]))
        except Exception: pass
    elif a.tcp:
        resp = tcp_probe(a.tcp)
    if a.rate_shell:
        out = subprocess.run(a.rate_shell, shell=True, capture_output=True, text=True, timeout=10).stdout
        rate = parse_tps(out) if a.game == "minecraft" else parse_fps(out)
    return rate, resp, players

def main(argv=None):
    ap = argparse.ArgumentParser(description="LagZero server agent")
    ap.add_argument("--game", default="minecraft", choices=["minecraft", "fivem", "rust", "ark", "valheim", "palworld", "other"])
    ap.add_argument("--rcon"); ap.add_argument("--rcon-pass", default=os.environ.get("RCON_PASSWORD", ""))
    ap.add_argument("--http", help="FiveM host:port"); ap.add_argument("--tcp", help="host:port to time a connect")
    ap.add_argument("--rate-cmd", help="RCON command that prints TPS/FPS"); ap.add_argument("--rate-shell", help="shell command that prints TPS/FPS")
    ap.add_argument("--log"); ap.add_argument("--samples", type=int, default=15); ap.add_argument("--interval", type=float, default=60)
    ap.add_argument("--pass", dest="token", default=os.environ.get("LAGZERO_PASS")); ap.add_argument("--json", action="store_true")
    ap.add_argument("--api", default=API)
    a = ap.parse_args(argv)
    if not (a.rcon or a.http or a.tcp or a.rate_shell): ap.error("give --rcon, --http, --tcp or --rate-shell")
    rcon = Rcon(*a.rcon.rsplit(":", 1)[0:1], int(a.rcon.rsplit(":", 1)[1]), a.rcon_pass) if a.rcon else None
    tail = LogTail(a.log); rate, resp, over, players = [], [], 0, None
    for i in range(a.samples):
        try:
            r, ms, p = sample_once(a, rcon, tail)
            if r is not None: rate.append(r)
            if ms is not None: resp.append(ms)
            if p is not None: players = p
        except Exception as e:
            print(f"sample {i+1}: {type(e).__name__}: {e}", file=sys.stderr)
        over += count_overruns(a.game, tail.new_lines())
        if i + 1 < a.samples: time.sleep(a.interval)
    cpu, mem = host_load()
    res = readout(build_payload(a.game, rate, resp, over, cpu, mem, players, a.token), a.api)
    if a.json: print(json.dumps(res, indent=1)); return 0 if res.get("ok") else 1
    if not res.get("ok"): print("LagZero:", res.get("error")); return 1
    print(f"\n{res['gameName']}: {res['face']}\n")
    for f in res["findings"]:
        print(f"[{f['status'].upper():5}] {f['name']}: {f['reading']}\n        {f['fix']}\n")
    return 0

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