#!/usr/bin/env python3
# =============================================================================
# NEW CITIZEN — SERVER BRIDGE v3 (nc_bridge.py)  ·  with schema AUTO-DETECT
# =============================================================================
# WHAT THIS DOES (plain English):
#   Runs beside your New Citizen dedicated server and connects your game — and
#   your game's own MySQL database — to the New Citizen Hub.
#
#     1. HEARTBEAT  every 30s: players online, weather, time -> live server
#        card + loading screens.
#     2. CONFIG     every 60s: owner-deck settings -> hub_config.json (your game
#        reads that file; website edits go live with no restart).
#     3. MYSQL      point it at your game database and it syncs banking, cash,
#        statements, live unit map positions, the online roster and DMV licence
#        re-tests. AUTO-DETECT probes your schema and maps whatever YOUR tables
#        and columns are called (tbl_bankaccount vs bank_accounts, playercode
#        vs citizenid vs identifier, ...) — zero game-code changes. The mapping
#        is cached in nc_mapping.json; delete that file to re-probe.
#     4. ADMIN      every 5s: runs admin-console commands (kick/ban/weather…)
#        through the rcon() hook at the bottom.
#     5. CREATOR    every 5s: runs Creator Studio capture commands (record,
#        stream, clip marks, overlay scenes) -> hub_capture_state.json +
#        hub_clips.log for your local capture stack to consume.
#     6. MISSIONS   every 5s: dispatch-generated police missions ->
#        hub_missions.json. Your game resource spawns the scenes/blips from
#        that file and despawns them when officers close the call.
#
# QUICK START:
#   pip install requests pymysql
#   put the .env from Owner Deck > Connect Game next to this script
#   python3 nc_bridge.py            run forever
#   python3 nc_bridge.py --test     connection doctor: hub systems + a full
#                                   schema report (what was auto-detected, and
#                                   ready-made CREATE TABLE statements for
#                                   anything missing)
#   python3 nc_bridge.py --remap    forget the cached mapping and re-probe
#
# The one-command installer (Owner Deck > Connect Game > Step 2) does the
# whole setup incl. a systemd service, and runs --test for you at the end.
# =============================================================================

import json
import os
import sys
import time
from datetime import datetime, timezone

import requests

# --------------------------- CONFIG (env first) ------------------------------

def _load_dotenv():
    env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
    try:
        with open(env_path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    k, v = line.split("=", 1)
                    os.environ.setdefault(k.strip(), v.strip())
    except FileNotFoundError:
        pass

_load_dotenv()

HUB_URL    = os.environ.get("NC_HUB")       or "https://specpixel.com/api"
SERVER_ID  = os.environ.get("NC_SERVER_ID") or "SRV-XXXXXXXX"
API_KEY    = os.environ.get("NC_API_KEY")   or "PASTE-YOUR-SERVER-API-KEY"

BASE_DIR   = os.path.dirname(os.path.abspath(__file__))
CONFIG_OUT = os.path.join(BASE_DIR, "hub_config.json")   # game reads this
STATE_IN   = os.path.join(BASE_DIR, "hub_state.json")    # game writes this (optional)
MAP_FILE   = os.path.join(BASE_DIR, "nc_mapping.json")   # auto-detected schema cache

MYSQL = {
    "host":     os.environ.get("NC_MYSQL_HOST", ""),
    "port":     int(os.environ.get("NC_MYSQL_PORT", "3306")),
    "user":     os.environ.get("NC_MYSQL_USER", "root"),
    "password": os.environ.get("NC_MYSQL_PASSWORD", ""),
    "database": os.environ.get("NC_MYSQL_DATABASE", "roleplay"),
}
MYSQL_ENABLED = bool(MYSQL["host"])

HEARTBEAT_EVERY = 30
CONFIG_EVERY    = 60
ADMIN_EVERY     = 5
POSITIONS_EVERY = 4
# -----------------------------------------------------------------------------

HEADERS = {"X-NC-Key": API_KEY, "Content-Type": "application/json"}


def post(path, payload):
    r = requests.post(f"{HUB_URL}{path}", headers=HEADERS, json=payload, timeout=15)
    if r.status_code >= 400:
        raise RuntimeError(f"POST {path} -> {r.status_code}: {r.text[:200]}")
    return r.json()


def get(path):
    r = requests.get(f"{HUB_URL}{path}", headers=HEADERS, timeout=15)
    if r.status_code >= 400:
        raise RuntimeError(f"GET {path} -> {r.status_code}: {r.text[:200]}")
    return r.json()


def read_state():
    try:
        with open(STATE_IN, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {"players": 0, "max_players": 64, "queue": 0, "map": "", "weather": "",
                "game_time": "", "on_duty": {}, "hostname": ""}


# ============================ SCHEMA AUTO-DETECT ==============================
# Six concepts the hub needs. For each we try preferred table names first, then
# scan EVERY table and score it by column fit. Columns resolve through aliases,
# so "citizenid" works just as well as "playercode". Anything not found is
# skipped safely and --test tells you exactly what to create.

COLUMN_ALIASES = {
    "playercode":  ["playercode", "citizenid", "citizen_id", "identifier", "pid", "player_id", "char_id", "character_id"],
    "balance":     ["balance", "bank", "bankbalance", "bank_balance", "amount"],
    "cash":        ["cash", "cashbalance", "cash_balance", "wallet", "money_cash"],
    "isblocked":   ["isblocked", "blocked", "frozen", "is_blocked"],
    "txid":        ["transactionhistoryid", "transaction_id", "txid", "id"],
    "amount":      ["amount", "value", "sum"],
    "description": ["description", "memo", "reason", "label", "note"],
    "frompc":      ["fromplayercode", "from_playercode", "sender", "from_citizenid"],
    "fee":         ["fee"],
    "timestamp":   ["timestamp", "created_at", "created", "date", "at"],
    "x":           ["x", "pos_x", "position_x", "coord_x"],
    "y":           ["y", "pos_y", "position_y", "coord_y"],
    "name":        ["name", "charname", "character_name", "fullname", "player_name"],
    "job":         ["job", "job_name", "faction", "occupation", "duty_job"],
    "online":      ["online", "is_online", "logged_in", "is_online_flag"],
    "rowid":       ["id", "test_id", "testid", "row_id"],
    "passed":      ["passed", "pass", "result", "success"],
    "examiner":    ["examiner", "instructor", "tester"],
}

# concept -> preferred tables, required logical cols, optional logical cols
CONCEPTS = {
    "bank":         {"tables": ["tbl_bankaccount", "bank_accounts", "bankaccount", "player_bank"],
                     "cols": ["playercode", "balance"], "optional": ["isblocked", "cash"]},
    "cash":         {"tables": ["tbl_cashbalance", "cash_balance", "cashbalance", "player_cash"],
                     "cols": ["playercode", "cash"], "optional": []},
    "transactions": {"tables": ["tbl_transactionhistory", "transaction_history", "bank_transactions", "transactions"],
                     "cols": ["txid", "playercode", "amount"], "optional": ["description", "frompc", "fee", "timestamp"]},
    "positions":    {"tables": ["tbl_positions", "player_positions", "positions", "unit_positions"],
                     "cols": ["playercode", "x", "y"], "optional": []},
    "players":      {"tables": ["tbl_players", "players", "characters", "user_characters"],
                     "cols": ["playercode", "name"], "optional": ["job", "online"]},
    "tests":        {"tables": ["tbl_driving_tests", "driving_tests", "dmv_tests"],
                     "cols": ["rowid", "playercode", "passed"], "optional": ["examiner"]},
    "deaths":       {"tables": ["tbl_deaths", "player_deaths", "deaths", "death_log"],
                     "cols": ["rowid", "playercode"], "optional": ["timestamp"]},
}

# what --test suggests creating when a concept is missing entirely
SUGGESTED_DDL = {
    "positions":    "CREATE TABLE tbl_positions (playercode INT PRIMARY KEY, x DOUBLE, y DOUBLE);",
    "players":      "CREATE TABLE tbl_players (playercode INT PRIMARY KEY, name VARCHAR(80), job VARCHAR(40), online TINYINT DEFAULT 0);",
    "tests":        "CREATE TABLE tbl_driving_tests (id INT AUTO_INCREMENT PRIMARY KEY, playercode INT, passed TINYINT, examiner VARCHAR(80), created TIMESTAMP DEFAULT CURRENT_TIMESTAMP);",
    "cash":         "CREATE TABLE tbl_cashbalance (playercode INT PRIMARY KEY, balance INT DEFAULT 0);",
    "bank":         "CREATE TABLE tbl_bankaccount (playercode INT PRIMARY KEY, balance INT DEFAULT 0, isblocked TINYINT DEFAULT 0);",
    "transactions": "CREATE TABLE tbl_transactionhistory (transactionhistoryid INT AUTO_INCREMENT PRIMARY KEY, playercode INT, amount INT, description VARCHAR(200), fromplayercode INT, fee INT DEFAULT 0, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP);",
    "deaths":       "CREATE TABLE tbl_deaths (id INT AUTO_INCREMENT PRIMARY KEY, playercode INT, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP);  -- your death resource inserts one row per death",
}


class Schema:
    """Resolved table + column mapping. .has()/.table()/.col() are all the
    sync functions ever touch — they never hard-code a table or column name."""
    def __init__(self, mapping):
        self.map = mapping or {}

    def has(self, concept):
        return concept in self.map

    def table(self, concept):
        return self.map[concept]["table"]

    def col(self, concept, logical, default=None):
        return self.map[concept]["cols"].get(logical, default)


def mysql_conn():
    import pymysql
    return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, **MYSQL)


def probe_schema(conn):
    """Walk the database and map every concept to the best-fitting table."""
    with conn.cursor() as cur:
        cur.execute("SHOW TABLES")
        names = [list(r.values())[0] for r in cur.fetchall()]
        cols_cache = {}

        def cols_of(t):
            if t not in cols_cache:
                cur.execute(f"SHOW COLUMNS FROM `{t}`")
                cols_cache[t] = {r["Field"].lower(): r["Field"] for r in cur.fetchall()}
            return cols_cache[t]

        def resolve(t, logical):
            low = cols_of(t)
            for alias in COLUMN_ALIASES[logical]:
                if alias in low:
                    return low[alias]
            return None

        mapping = {}
        for concept, spec in CONCEPTS.items():
            order = [t for t in spec["tables"] if t in names] + [t for t in names if t not in spec["tables"]]
            for t in order[:80]:
                cols, ok = {}, True
                for logical in spec["cols"]:
                    actual = resolve(t, logical)
                    if not actual:
                        ok = False
                        break
                    cols[logical] = actual
                if not ok:
                    continue
                for logical in spec.get("optional", []):
                    actual = resolve(t, logical)
                    if actual:
                        cols[logical] = actual
                mapping[concept] = {"table": t, "cols": cols}
                break
        return mapping


def load_schema(remap=False):
    """Cached mapping wins; otherwise probe and cache. Returns (Schema, origin)."""
    if not remap:
        try:
            with open(MAP_FILE, "r", encoding="utf-8") as f:
                return Schema(json.load(f)), "cached nc_mapping.json"
        except Exception:
            pass
    conn = mysql_conn()
    try:
        mapping = probe_schema(conn)
    finally:
        conn.close()
    with open(MAP_FILE, "w", encoding="utf-8") as f:
        json.dump(mapping, f, indent=2)
    return Schema(mapping), "auto-detected (saved to nc_mapping.json)"


def describe_schema(schema):
    for concept in CONCEPTS:
        if schema.has(concept):
            cols = ", ".join(f"{k}={v}" for k, v in schema.map[concept]["cols"].items())
            print(f"  [map] {concept:<13} -> {schema.table(concept)}  ({cols})")
        else:
            print(f"  [map] {concept:<13} -> not found")
            if concept in SUGGESTED_DDL:
                print(f"        suggest: {SUGGESTED_DDL[concept]}")


# ------------------------------ sync functions -------------------------------

def sync_accounts(schema):
    if not schema.has("bank"):
        return
    b_t, pc, bal = schema.table("bank"), schema.col("bank", "playercode"), schema.col("bank", "balance")
    blocked = schema.col("bank", "isblocked")
    cash_same = schema.col("bank", "cash")  # cash column living inside the bank table
    if cash_same:
        sql = (f"SELECT `{pc}` AS playercode, `{bal}` AS bank, `{cash_same}` AS cash, "
               f"{f'`{blocked}`' if blocked else '0'} AS isblocked FROM `{b_t}`")
    elif schema.has("cash"):
        c_t, cpc, cbal = schema.table("cash"), schema.col("cash", "playercode"), schema.col("cash", "cash")
        sql = (f"SELECT b.`{pc}` AS playercode, b.`{bal}` AS bank, COALESCE(c.`{cbal}`, 0) AS cash, "
               f"{f'b.`{blocked}`' if blocked else '0'} AS isblocked FROM `{b_t}` b "
               f"LEFT JOIN `{c_t}` c ON c.`{cpc}` = b.`{pc}`")
    else:
        sql = f"SELECT `{pc}` AS playercode, `{bal}` AS bank, 0 AS cash, {f'`{blocked}`' if blocked else '0'} AS isblocked FROM `{b_t}`"
    conn = mysql_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(sql)
            rows = cur.fetchall()
        payload = [{"playercode": r["playercode"], "bank": int(r["bank"]),
                    "cash": int(r["cash"]), "isblocked": bool(r["isblocked"])} for r in rows]
        if payload:
            print(post(f"/nc/servers/{SERVER_ID}/sync/accounts", payload)["message"])
    finally:
        conn.close()


_last_txid = 0

def sync_transactions(schema):
    global _last_txid
    if not schema.has("transactions"):
        return
    t_t = schema.table("transactions")
    txid, pc, amt = (schema.col("transactions", k) for k in ("txid", "playercode", "amount"))
    desc, frompc, fee, ts = (schema.col("transactions", k) for k in ("description", "frompc", "fee", "timestamp"))
    select = [f"`{txid}` AS txid", f"`{pc}` AS playercode", f"`{amt}` AS amount",
              f"`{desc}` AS description" if desc else "'' AS description",
              f"`{frompc}` AS fromplayercode" if frompc else "0 AS fromplayercode",
              f"`{fee}` AS fee" if fee else "0 AS fee",
              f"`{ts}` AS timestamp" if ts else "NULL AS timestamp"]
    conn = mysql_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(f"SELECT {', '.join(select)} FROM `{t_t}` WHERE `{txid}` > %s ORDER BY `{txid}` LIMIT 500", (_last_txid,))
            rows = cur.fetchall()
        if not rows:
            return
        for r in rows:
            r["timestamp"] = r["timestamp"].isoformat() if r.get("timestamp") else ""
        print(post(f"/nc/servers/{SERVER_ID}/sync/transactions", rows)["message"])
        _last_txid = max(r["txid"] for r in rows)
    finally:
        conn.close()


def execute_pending_transfers(schema):
    """Web-phone transfers (incl. court fines) apply inside the game DB."""
    if not schema.has("bank") or not schema.has("transactions"):
        return
    pending = get(f"/nc/servers/{SERVER_ID}/bank/pending")
    if not pending:
        return
    b_t, b_pc, b_bal = schema.table("bank"), schema.col("bank", "playercode"), schema.col("bank", "balance")
    t_t = schema.table("transactions")
    tx = {k: schema.col("transactions", k) for k in ("txid", "playercode", "amount", "description", "frompc", "timestamp")}
    conn = mysql_conn()
    try:
        with conn.cursor() as cur:
            for t in pending:
                try:
                    is_fine = t.get("kind") == "fine"
                    cur.execute(f"UPDATE `{b_t}` SET `{b_bal}` = `{b_bal}` - %s WHERE `{b_pc}` = %s AND `{b_bal}` >= %s",
                                (t["amount"], t["from_playercode"], t["amount"]))
                    if cur.rowcount != 1:
                        raise RuntimeError("insufficient funds or missing account")
                    if not is_fine:
                        cur.execute(f"UPDATE `{b_t}` SET `{b_bal}` = `{b_bal}` + %s WHERE `{b_pc}` = %s",
                                    (t["amount"], t["to_playercode"]))
                        if cur.rowcount != 1:
                            raise RuntimeError("target account missing")
                    legs = [(t["from_playercode"], -t["amount"])] if is_fine else [(t["from_playercode"], -t["amount"]), (t["to_playercode"], t["amount"])]
                    memo = t.get("memo") or f"Web phone transfer #{t['id'][:8]}"
                    for code, signed in legs:
                        cur.execute(f"SELECT `{b_bal}` AS balance FROM `{b_t}` WHERE `{b_pc}` = %s", (code,))
                        acc = cur.fetchone()
                        cols = [tx["playercode"], tx["amount"]]
                        vals = [code, signed]
                        if tx["frompc"]:
                            cols.append(tx["frompc"]); vals.append(t["from_playercode"])
                        if tx["description"]:
                            cols.append(tx["description"]); vals.append(memo)
                        if tx["timestamp"]:
                            cols.append(tx["timestamp"]); vals.append(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
                        cur.execute(f"INSERT INTO `{t_t}` ({', '.join('`' + c + '`' for c in cols)}) VALUES ({', '.join(['%s'] * len(vals))})", vals)
                    conn.commit()
                    post(f"/nc/servers/{SERVER_ID}/bank/complete", {"transfer_id": t["id"], "ok": True})
                    print(f"transfer {t['id'][:8]}: {t['amount']} from {t['from_playercode']} -> {t['to_playercode']} OK")
                except Exception as exc:
                    conn.rollback()
                    post(f"/nc/servers/{SERVER_ID}/bank/complete",
                         {"transfer_id": t["id"], "ok": False, "reason": str(exc)[:180]})
                    print(f"transfer {t['id'][:8]} FAILED: {exc}")
    finally:
        conn.close()


def sync_positions(schema):
    """Live unit markers on the MDT map. Hub matches playercode -> checked-in units."""
    if not schema.has("positions"):
        return
    t_t, pc, x, y = schema.table("positions"), schema.col("positions", "playercode"), schema.col("positions", "x"), schema.col("positions", "y")
    conn = mysql_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(f"SELECT `{pc}` AS playercode, `{x}` AS x, `{y}` AS y FROM `{t_t}`")
            rows = cur.fetchall()
        if rows:
            post(f"/nc/servers/{SERVER_ID}/cad/units/positions",
                 {"positions": [{"playercode": r["playercode"], "x": float(r["x"]), "y": float(r["y"])} for r in rows]})
    finally:
        conn.close()


def mysql_roster(schema):
    """Online roster for the heartbeat. Optional — hub_state.json works too."""
    if not schema.has("players"):
        return None
    t_t = schema.table("players")
    pc, name = schema.col("players", "playercode"), schema.col("players", "name")
    job, online = schema.col("players", "job"), schema.col("players", "online")
    where = f" WHERE `{online}` = 1" if online else ""
    conn = mysql_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(f"SELECT `{pc}` AS playercode, `{name}` AS name{', `' + job + '` AS job' if job else ''} FROM `{t_t}`{where}")
            return [{"playercode": r["playercode"], "name": r["name"], "job": r.get("job", ""), "ping": 0} for r in cur.fetchall()]
    finally:
        conn.close()


_last_testid = 0

def sync_driving_tests(schema):
    """DMV re-test results -> licence reinstatement (pass lifts the suspension,
    clears points and texts the driver from the DMV)."""
    global _last_testid
    if not schema.has("tests"):
        return
    t_t = schema.table("tests")
    rid, pc, passed = (schema.col("tests", k) for k in ("rowid", "playercode", "passed"))
    examiner = schema.col("tests", "examiner")
    conn = mysql_conn()
    try:
        with conn.cursor() as cur:
            cur.execute(f"SELECT `{rid}` AS id, `{pc}` AS playercode, `{passed}` AS passed"
                        f"{', `' + examiner + '` AS examiner' if examiner else ''} "
                        f"FROM `{t_t}` WHERE `{rid}` > %s ORDER BY `{rid}` LIMIT 100", (_last_testid,))
            rows = cur.fetchall()
        for r in rows:
            msg = post(f"/nc/servers/{SERVER_ID}/cad/licence/{r['playercode']}/test",
                       {"passed": bool(r["passed"]), "examiner": r.get("examiner") or "DMV"})["message"]
            print(f"[dmv] {msg}")
            _last_testid = max(_last_testid, r["id"])
    finally:
        conn.close()


# ------------------------------- hub <-> game --------------------------------

def heartbeat(schema):
    state = read_state()
    if MYSQL_ENABLED:
        roster = mysql_roster(schema)
        if roster is not None:
            state["roster"] = roster
            state["players"] = len(roster)
    post(f"/nc/servers/{SERVER_ID}/state", state)
    print(f"[{datetime.now():%H:%M:%S}] heartbeat: {state.get('players', 0)} players online")


def pull_config():
    cfg = get(f"/nc/servers/{SERVER_ID}/config")
    with open(CONFIG_OUT, "w", encoding="utf-8") as f:
        json.dump(cfg, f, indent=2)
    print(f"[{datetime.now():%H:%M:%S}] config synced -> {CONFIG_OUT}")


def mint_session(playercode, role=""):
    """Call this when a player opens a device. Returns the CEF URL token.
    role: police | ems | fire | judge | admin (blank = citizen phone)."""
    r = post(f"/nc/servers/{SERVER_ID}/session", {"playercode": playercode, "role": role})
    return r["token"]


def send_911(transcript, caller, caller_playercode, location_label="", x=None, y=None):
    """In-game 911 call -> AI dispatcher -> every police MDT + the caller's phone."""
    post(f"/nc/servers/{SERVER_ID}/911", {
        "transcript": transcript, "caller": caller, "caller_playercode": caller_playercode,
        "location_label": location_label, "x": x, "y": y,
    })


# ------------------------------ admin commands -------------------------------

def rcon(line):
    """One line to your server's console. Replace with your real control channel
    (RCON packet, ACE pipe, watched commands.txt). Raise on failure."""
    print(f"[admin-exec] {line}")  # TODO: send `line` to the actual game server


def execute_admin_command(cmd):
    t = cmd.get("type")
    target = cmd.get("target_name") or cmd.get("target_playercode")
    reason = cmd.get("reason", "").replace('"', "'")
    p = cmd.get("params") or {}
    try:
        if t == "kick":      rcon(f'kick {target} "{reason}"')
        elif t == "ban":     rcon(f'ban {target} "{reason}"')
        elif t == "unban":   rcon(f'unban {target}')
        elif t == "warn":    rcon(f'warn {target} "{reason}"')
        elif t == "announce": rcon(f'announce "{reason}"')
        elif t == "set_weather": rcon(f'weather {p.get("weather", "clear")}')
        elif t == "set_time":    rcon(f'time {int(p.get("hour", 12)):02d}:00')
        elif t == "bring":   rcon(f'bring {target}')
        elif t == "goto":    rcon(f'goto {target}')
        elif t == "freeze":  rcon(f'freeze {target}')
        elif t == "heal":    rcon(f'heal {target}')
        else:
            return False, f"unknown command type {t}"
        return True, ""
    except Exception as exc:
        return False, str(exc)[:200]


def poll_admin_commands():
    cmds = get(f"/nc/servers/{SERVER_ID}/admin/commands/pending").get("commands", [])
    for cmd in cmds:
        ok, err = execute_admin_command(cmd)
        post(f"/nc/servers/{SERVER_ID}/admin/commands/ack",
             {"command_id": cmd["id"], "ok": ok, "error": err})
        if cmd["type"] in ("kick", "ban", "warn", "announce"):
            print(f"[admin] {cmd['type']} -> {cmd.get('target_name') or cmd.get('target_playercode')} ({'ok' if ok else err})")



# ------------------------------ econ + deploy ops ----------------------------

DEPLOY_OUT = os.path.join(BASE_DIR, "hub_deploy_queue.json")  # your game resource polls this file

def execute_econ_op(schema, op):
    """Ops queued by web mini-games: casino/crypto/heist bank adjustments and
    join-flow deploy (spawn + faction) requests."""
    kind = op.get("kind")
    if kind == "bank_adjust":
        if not schema.has("bank"):
            return False, "no bank table mapping — set NC_MYSQL_* and run --remap"
        b_t, b_pc, b_bal = schema.table("bank"), schema.col("bank", "playercode"), schema.col("bank", "balance")
        conn = mysql_conn()
        try:
            with conn.cursor() as cur:
                cur.execute(f"UPDATE `{b_t}` SET `{b_bal}` = `{b_bal}` + %s WHERE `{b_pc}` = %s",
                            (op["amount"], op["playercode"]))
                if cur.rowcount != 1:
                    raise RuntimeError("account row not found")
            conn.commit()
            return True, ""
        except Exception as exc:
            conn.rollback()
            return False, str(exc)[:200]
        finally:
            conn.close()
    if kind == "deploy":
        # the hub can't spawn directly — your game resource reads hub_deploy_queue.json
        # on player connect and places them (spawn_id + faction_id + character name)
        try:
            queue = []
            if os.path.exists(DEPLOY_OUT):
                with open(DEPLOY_OUT, encoding="utf-8") as f:
                    queue = json.load(f)
            queue.append({"playercode": op.get("playercode"), "spawn_id": op.get("spawn_id"),
                          "faction_id": op.get("faction_id"), "character_name": op.get("character_name", "")})
            with open(DEPLOY_OUT, "w", encoding="utf-8") as f:
                json.dump(queue, f, indent=2)
            return True, ""
        except Exception as exc:
            return False, str(exc)[:200]
    return False, f"unknown op kind {kind}"


def poll_econ_ops(schema):
    ops = get(f"/nc/servers/{SERVER_ID}/econ/ops/pending").get("ops", [])
    for op in ops:
        ok, err = execute_econ_op(schema, op)
        post(f"/nc/servers/{SERVER_ID}/econ/ops/ack", {"op_id": op["id"], "ok": ok, "error": err})
        print(f"[econ] {op.get('kind')} pc:{op.get('playercode')} ({'ok' if ok else err})")


# ------------------------------ creator studio commands ----------------------

CAPTURE_OUT = os.path.join(BASE_DIR, "hub_capture_state.json")  # your capture stack reads this
CLIPS_LOG   = os.path.join(BASE_DIR, "hub_clips.log")           # clip markers land here


def execute_creator_command(cmd):
    """Creator Studio actions (record/stream/clip/overlay) queued from the in-game
    capture deck. Drives your local capture stack: recording/streaming flags and the
    overlay scene set are written to hub_capture_state.json; clip markers append to
    hub_clips.log. Hook OBS/replay-buffer here if you run one — return (ok, error)."""
    try:
        state = {"recording": False, "streaming": False, "overlays": {}}
        if os.path.exists(CAPTURE_OUT):
            with open(CAPTURE_OUT, encoding="utf-8") as f:
                state = json.load(f)
        a = cmd.get("action")
        if a == "record_start":
            state["recording"] = True
        elif a == "record_stop":
            state["recording"] = False
        elif a == "stream_start":
            state["streaming"] = True
        elif a == "stream_stop":
            state["streaming"] = False
        elif a == "overlay":
            state.setdefault("overlays", {})[cmd.get("overlay", "")] = bool(cmd.get("enabled", True))
        elif a == "clip_mark":
            with open(CLIPS_LOG, "a", encoding="utf-8") as f:
                f.write(f"{datetime.now(timezone.utc).isoformat()} pc:{cmd.get('playercode')} {cmd.get('label') or 'clip'}\n")
        else:
            return False, f"unknown creator action {a}"
        state["updated_at"] = datetime.now(timezone.utc).isoformat()
        with open(CAPTURE_OUT, "w", encoding="utf-8") as f:
            json.dump(state, f, indent=2)
        return True, ""
    except Exception as exc:
        return False, str(exc)[:200]


def poll_creator_commands():
    cmds = get(f"/nc/servers/{SERVER_ID}/creator/commands/pending").get("commands", [])
    for cmd in cmds:
        ok, err = execute_creator_command(cmd)
        post(f"/nc/servers/{SERVER_ID}/creator/commands/ack",
             {"command_id": cmd["id"], "ok": ok, "error": err})
        print(f"[creator] {cmd.get('action')} pc:{cmd.get('playercode')} ({'ok' if ok else err})")


# ------------------------------ police missions ------------------------------

MISSIONS_OUT = os.path.join(BASE_DIR, "hub_missions.json")  # game resource reads this each tick


def poll_missions():
    """Mirror dispatch-generated police missions for the game world: unspawned
    missions are acked once written, and the full open board lands in
    hub_missions.json every tick — the game resource spawns scenes/blips from it
    and despawns them when officers close the call on the MDT."""
    pending = get(f"/nc/servers/{SERVER_ID}/missions/pending").get("missions", [])
    for m in pending:
        post(f"/nc/servers/{SERVER_ID}/missions/ack", {"mission_id": m["id"], "ok": True})
        print(f"[missions] {m.get('ref')} — {m.get('mission', {}).get('title', '')} queued for scene spawn")
    open_m = get(f"/nc/servers/{SERVER_ID}/missions/open").get("missions", [])
    slim = [{"id": m["id"], "ref": m.get("ref", ""),
             "title": m.get("mission", {}).get("title", ""),
             "objective": m.get("mission", {}).get("objective", ""),
             "reward": m.get("mission", {}).get("reward", 0),
             "priority": m.get("priority", 3),
             "location_label": (m.get("location") or {}).get("label", ""),
             "status": m.get("status", "open"), "assigned_unit": m.get("assigned_unit", ""),
             "expires_at": m.get("mission", {}).get("expires_at", "")} for m in open_m]
    with open(MISSIONS_OUT, "w", encoding="utf-8") as f:
        json.dump(slim, f, indent=2)


# ------------------------------ mayor death trigger ----------------------------

EVENTS_IN = os.path.join(BASE_DIR, "hub_events.json")  # game writes [{"type":"death","playercode":N}, ...]
_last_death_id = 0

def _mayor_down_if_mayor(playercode, src):
    try:
        gov = get(f"/nc/servers/{SERVER_ID}/politics").get("government")
        if gov and gov.get("playercode") == playercode:
            r = post(f"/nc/servers/{SERVER_ID}/politics/mayor-down", {"playercode": playercode})
            print(f"[politics] mayor death detected via {src}: {r.get('message')}")
    except Exception as exc:
        print(f"[warn] mayor-down check failed: {exc}")


def check_mayor_death(schema):
    """Two channels: (1) MySQL deaths table (auto-detected 'deaths' concept),
    (2) hub_events.json written by the game resource on player death."""
    global _last_death_id
    # file channel — easiest for game-side scripts (works with no MySQL mapping)
    if os.path.exists(EVENTS_IN):
        try:
            with open(EVENTS_IN, "r", encoding="utf-8") as f:
                events = json.load(f)
            deaths = [e for e in events if e.get("type") == "death" and e.get("playercode")]
            if deaths:
                with open(EVENTS_IN, "w", encoding="utf-8") as f:
                    json.dump([e for e in events if not (e.get("type") == "death" and e.get("playercode"))], f)
                for e in deaths:
                    _mayor_down_if_mayor(int(e["playercode"]), "hub_events.json")
        except (json.JSONDecodeError, ValueError):
            pass  # game is mid-write — try again next tick
    # mysql channel
    if schema.has("deaths"):
        t_t = schema.table("deaths")
        rid, pc = schema.col("deaths", "rowid"), schema.col("deaths", "playercode")
        conn = mysql_conn()
        try:
            with conn.cursor() as cur:
                cur.execute(f"SELECT `{rid}` AS rid, `{pc}` AS playercode FROM `{t_t}` WHERE `{rid}` > %s ORDER BY `{rid}` LIMIT 200", (_last_death_id,))
                rows = cur.fetchall()
            for r in rows:
                _last_death_id = max(_last_death_id, r["rid"])
                _mayor_down_if_mayor(int(r["playercode"]), "mysql deaths table")
        finally:
            conn.close()


# ------------------------------ doctor (--test) -------------------------------

def doctor():
    """One-shot connection test + schema report — same systems the owner-deck
    wizard shows, plus exactly what was auto-detected in your MySQL."""
    print("NEW CITIZEN BRIDGE — connection doctor")
    print(f"  hub: {HUB_URL}\n  server: {SERVER_ID}\n  mysql: {'enabled (' + MYSQL['host'] + ')' if MYSQL_ENABLED else 'disabled (set NC_MYSQL_* in .env)'}")
    try:
        h = get(f"/nc/servers/{SERVER_ID}/bridge/health")
        for s in h["systems"]:
            mark = "OK  " if s["ok"] else ("MISS" if s["critical"] else "----")
            print(f"  [{mark}] {s['label']:<18} {s['detail']}")
        print(f"  => {'RELEASE READY' if h['release_ready'] else 'blockers: ' + ', '.join(h['blockers'])}")
    except Exception as exc:
        print(f"  [FAIL] {exc}")
        sys.exit(1)
    if MYSQL_ENABLED:
        try:
            schema, origin = load_schema(remap=True)
            print(f"  schema: {origin}")
            describe_schema(schema)
        except Exception as exc:
            print(f"  [FAIL] mysql: {exc}")
            sys.exit(1)


def main():
    if "--test" in sys.argv:
        doctor()
        return
    remap = "--remap" in sys.argv
    print("=" * 70)
    print("NEW CITIZEN BRIDGE v3 — connecting", SERVER_ID)
    print("=" * 70)
    schema = Schema({})
    if MYSQL_ENABLED:
        try:
            schema, origin = load_schema(remap=remap)
            print(f"[mysql] schema mapping: {origin}")
            describe_schema(schema)
        except Exception as exc:
            print(f"[warn] mysql probe failed ({exc}) — banking/positions/licence sync paused until it connects")
    last_beat = last_cfg = last_bank = last_admin = last_pos = 0.0
    while True:
        now = time.time()
        try:
            if now - last_beat >= HEARTBEAT_EVERY:
                heartbeat(schema)
                last_beat = now
            if now - last_cfg >= CONFIG_EVERY:
                pull_config()
                last_cfg = now
            if now - last_admin >= ADMIN_EVERY:
                poll_admin_commands()
                poll_econ_ops(schema)   # casino/crypto/heist bank adjusts + join deploys
                poll_creator_commands() # creator studio record/stream/clip/overlay
                poll_missions()         # dispatch police missions -> hub_missions.json
                check_mayor_death(schema)  # mayor dies -> office vacates (unless persistent)
                last_admin = now
            if MYSQL_ENABLED and now - last_bank >= 15:
                sync_accounts(schema)
                sync_transactions(schema)
                execute_pending_transfers(schema)
                sync_driving_tests(schema)
                last_bank = now
            if MYSQL_ENABLED and now - last_pos >= POSITIONS_EVERY:
                sync_positions(schema)
                last_pos = now
        except Exception as exc:
            print(f"[warn] {exc}")   # never die on a network hiccup
        time.sleep(2)


if __name__ == "__main__":
    main()
