#!/bin/bash
# ============================================================================
# council-watch.sh — 議會監察單一腳本
#
# 將原來兩條 cron（tg-relay-minute-watch＋arena-recall-watch）合併為一個
# 獨立指令，搬去任何一部機都用到：
#   1. relay 事件 poll（經 SSH 到議會主機，用 server 上嘅 signed client）
#   2. TG 群快睇（tg.py；冇就用 Bot API＋WATCH_TG_BOT_TOKEN）
#   3. Arena 召回檢查（headless chromium＋playwright，閒置先發召回句）
#
# 搬機步驟：
#   1. 複製呢個檔去新機（一個檔搞掂，python 模組內嵌喺下面 heredoc）
#   2. 改下面「配置區」嘅環境變數（或喺外面 export 再跑）
#   3. 裝依賴：python3＋playwright（pip install playwright）、chromium
#   4. 準備一個登入咗 Arena agent 擁有者 Google 帳戶嘅 Chrome profile，
#      指去 WATCH_ARENA_PROFILE（CP 返嚟就得，保留登入態）
#   5. 用 cron 或 systemd timer 定時跑（特別模式每 30 秒，平時 1–2 分鐘）
#
# 用法：
#   ./council-watch.sh                  # 跑晒三步
#   ./council-watch.sh --only-relay     # 只做 relay poll
#   ./council-watch.sh --only-tg        # 只做 TG 快睇
#   ./council-watch.sh --only-arena     # 只做 Arena 檢查
#   ./council-watch.sh --no-arena       # 跳過 Arena（relay＋TG）
#   ./council-watch.sh --arena-debug    # Arena 出 screenshot＋DOM 診斷
#
# 結束碼：0＝正常；1＝有嘢要人睇（relay 斷／登出／要手機驗證等）；
#         2＝腳本自身錯誤（缺依賴／配置錯）
# ============================================================================

set -u

# ============================ 配置區（搬機改呢度） ============================
# 全部可用環境變數覆蓋，例如：WATCH_SERVER_HOST=1.2.3.4 ./council-watch.sh

: "${WATCH_SERVER_HOST:=149.118.48.126}"     # 議會主機（relay signed client 喺呢部機上面跑）
: "${WATCH_SSH_USER:=root}"
: "${WATCH_SSH_KEY:=$HOME/.ssh/id_ed25519}"  # SSH private key 路徑
: "${WATCH_SSH_TIMEOUT:=20}"
# WATCH_SSH_PROXYCMD：經跳板／egress proxy 先需要。呢部 VM 要經 CONNECT proxy：
#   export WATCH_SSH_PROXYCMD="python3 $HOME/workspace/ssh-via-proxy.py %h %p"
# 新機直連就留空。
: "${WATCH_SSH_PROXYCMD:=}"

: "${WATCH_RELAY_DIR:=/root/dj-relay}"        # server 上 dj-relay 目錄
: "${WATCH_RELAY_ROOM:=COMMONS}"
: "${WATCH_CURSOR_FILE:=$HOME/.council-watch/relay.cursor}"  # 本機 cursor 檔
: "${WATCH_A2_SENDER:=A25193}"                # A2 喺 relay 嘅 sender 名
: "${WATCH_HOST_SENDER:=dj_host1}"            # 主持喺 relay 嘅 sender 名

: "${WATCH_TG_PY:=$HOME/workspace/skills/telegram-bridge/bin/tg.py}"
: "${WATCH_TG_BOT_TOKEN:=}"                  # 冇 tg.py 先用：Telegram Bot token（唔好寫入檔，用 env 傳入）
: "${WATCH_TG_OFFSET_FILE:=$HOME/.council-watch/tg.offset}"
: "${WATCH_TG_CHAT_ID:=}"                    # 直接用 Bot API 時要睇嘅 chat id（tg.py 唔需要）
: "${WATCH_OWNER_PATTERN:=^(or|kim)$}"        # owner TG 名（regex，大小寫唔敏感）

: "${WATCH_RECALL_PHRASE:=Continue session 2147483647}"  # Arena 固定召回句（一字不加）
: "${WATCH_ARENA_URL:=https://arena.ai/agent/01a0c824-c3d5-783b-9499-026f6b68587d}"
: "${WATCH_ARENA_PROFILE:=$HOME/.council-watch/arena-profile}"  # 登入咗 agent 擁有者帳戶嘅 Chrome profile
: "${WATCH_CHROME_BIN:=}"                    # 空＝自動搵（/opt/meta-chromium/chrome 等）
# WATCH_UPSTREAM_PROXY：部機出唔到街要經 proxy 先設（chromium 做唔到帶 auth 嘅
# proxy，腳本會自動起個本地中轉）。格式：http://user:pass@host:port
# 留空＝跟 https_proxy/HTTPS_PROXY 環境變數；設為 "direct"＝唔經 proxy。
: "${WATCH_UPSTREAM_PROXY:=}"
: "${WATCH_RELAY_PORT:=18888}"               # 本地中轉 proxy 個 port（0＝唔起）

: "${WATCH_DAILY_LOG:=}"                     # 例如 $HOME/memory/$(date +%F).md；空＝唔寫 log
: "${WATCH_QUIET:=0}"                        # 1＝只印重點（啱 cron 用）
# ============================================================================

# 內嵌 python 要讀嘅變數逐個 export（set -u 下未 export 會 KeyError）
export WATCH_CURSOR_FILE WATCH_A2_SENDER WATCH_HOST_SENDER WATCH_OWNER_PATTERN
export WATCH_TG_OFFSET_FILE WATCH_TG_CHAT_ID
export WATCH_ARENA_URL WATCH_ARENA_PROFILE WATCH_CHROME_BIN WATCH_RECALL_PHRASE

TMPDIR_WORK="$(mktemp -d "${TMPDIR:-/tmp}/council-watch.XXXXXX")"
trap 'rm -rf "$TMPDIR_WORK"; [ -n "${RELAY_PID:-}" ] && kill "$RELAY_PID" 2>/dev/null' EXIT
NEED_ATTENTION=0
ARENA_DEBUG=0

log()  { [ "$WATCH_QUIET" = "1" ] || echo "[$(date '+%H:%M:%S')] $*"; }
warn() { echo "[$(date '+%H:%M:%S')] ⚠ $*" >&2; NEED_ATTENTION=1; }
die()  { echo "[$(date '+%H:%M:%S')] ✘ $*" >&2; exit 2; }

daily_log() {
  [ -n "$WATCH_DAILY_LOG" ] || return 0
  mkdir -p "$(dirname "$WATCH_DAILY_LOG")"
  echo "- $(date '+%H:%M') council-watch：$*" >> "$WATCH_DAILY_LOG"
}

# ---------------- SSH ----------------
ssh_cmd() {
  local cmd="ssh -o BatchMode=yes -o ConnectTimeout=$WATCH_SSH_TIMEOUT -o StrictHostKeyChecking=accept-new -i $WATCH_SSH_KEY"
  if [ -n "$WATCH_SSH_PROXYCMD" ]; then
    cmd="$cmd -o ProxyCommand=\"$WATCH_SSH_PROXYCMD\""
  fi
  printf '%s %s@%s' "$cmd" "$WATCH_SSH_USER" "$WATCH_SERVER_HOST"
}

# ---------------- 1. relay poll ----------------
step_relay() {
  log "relay poll…"
  [ -f "$WATCH_SSH_KEY" ] || die "SSH key 唔存在：$WATCH_SSH_KEY"
  mkdir -p "$(dirname "$WATCH_CURSOR_FILE")"
  local cursor="47"
  if [ -f "$WATCH_CURSOR_FILE" ]; then
    cursor="$(tr -cd '0-9' < "$WATCH_CURSOR_FILE" 2>/dev/null)"
    [ -n "$cursor" ] || cursor="47"
  fi

  local out rc=0
  # 經 stdin heredoc 送去 server 嘅 bash -s，避免多層引號疊床架屋
  out="$(eval "$(ssh_cmd) bash -s" <<EOF 2>"$TMPDIR_WORK/ssh.err"
cd "$WATCH_RELAY_DIR" || exit 9
printf '{"after_sequence":$cursor,"wait_seconds":10}' > /tmp/cw-p.json
chmod 600 /tmp/cw-p.json
python3 ./arc_agent_client.py request POST /v1/events/poll --body /tmp/cw-p.json
rc=\$?
rm -f /tmp/cw-p.json
exit \$rc
EOF
)" || rc=$?
  if [ "$rc" -ne 0 ]; then
    warn "relay poll 失敗（rc=$rc）：$(head -c 200 "$TMPDIR_WORK/ssh.err")"
    daily_log "relay poll 失敗"
    return 1
  fi
  echo "$out" > "$TMPDIR_WORK/poll.json"

  python3 - "$TMPDIR_WORK/poll.json" <<'PYEOF'
import json, os, re, sys
raw = open(sys.argv[1], encoding="utf-8", errors="replace").read()
try:
    d = json.loads(raw)
except Exception as e:
    print("POLL_PARSE_FAIL " + str(e)[:120]); sys.exit(0)
resp = d.get("response", d)
evs = resp.get("events", []) or []
cf = os.environ["WATCH_CURSOR_FILE"]
a2 = os.environ["WATCH_A2_SENDER"]
try:
    cur = int((open(cf).read().strip() or "47"))
except Exception:
    cur = 47
mx, new_a2, new_owner = cur, [], []
for e in evs:
    try: seq = int(e.get("sequence", 0))
    except Exception: continue
    mx = max(mx, seq)
    if seq <= cur: continue
    sender = str(e.get("sender", ""))
    text = str(e.get("text", "") or e.get("body", "") or "")
    if sender == a2: new_a2.append((seq, text))
    elif sender.upper() == "OWNER": new_owner.append((seq, text))
open(cf, "w").write(str(mx))
print("cursor -> %d" % mx)
for seq, t in new_a2 + new_owner:
    tag = "A2" if (seq, t) in new_a2 else "OWNER"
    print("== %s seq=%d ==" % (tag, seq)); print(t[:3000]); print("--")
print("summary: A2新帖=%d OWNER新帖=%d" % (len(new_a2), len(new_owner)))
PYEOF
  daily_log "relay poll ok"
}

# ---------------- 2. TG 快睇 ----------------
step_tg() {
  log "TG 快睇…"
  if [ -x "$WATCH_TG_PY" ]; then
    local poll_out
    poll_out="$(python3 "$WATCH_TG_PY" poll 2>/dev/null)" || { warn "tg.py poll 失敗"; return 1; }
    log "tg poll：$poll_out"
    local recent
    recent="$(python3 "$WATCH_TG_PY" recent 10 2>/dev/null)" || { warn "tg.py recent 失敗"; return 1; }
    # 搵 owner 新訊息（頭 10 則入面對名）
    if echo "$recent" | python3 -c "
import json,sys,re,os
pat = re.compile(os.environ['WATCH_OWNER_PATTERN'], re.I)
try: msgs = json.loads(sys.stdin.read())
except Exception: sys.exit(1)
msgs = msgs if isinstance(msgs, list) else msgs.get('messages', [])
hit = [m for m in msgs if pat.search(str(m.get('from', m.get('sender', ''))))]
print('OWNER_HIT %d' % len(hit))
for m in hit[:3]:
    print('owner:', str(m.get('text', ''))[:300])
" | grep -q "OWNER_HIT [1-9]"; then
      warn "TG 有 owner 新訊息"
      echo "$recent" | python3 -c "
import json,sys,re,os
pat = re.compile(os.environ['WATCH_OWNER_PATTERN'], re.I)
msgs = json.loads(sys.stdin.read()); msgs = msgs if isinstance(msgs, list) else msgs.get('messages', [])
for m in msgs:
    if pat.search(str(m.get('from', m.get('sender', '')))): print('owner:', str(m.get('text',''))[:500]); print('--')
"
    else
      log "TG 無 owner 新訊息"
    fi
    daily_log "TG 快睇 ok"
    return 0
  fi
  # fallback：直連 Bot API
  [ -n "$WATCH_TG_BOT_TOKEN" ] || { log "TG：冇 tg.py 又冇 token，跳過"; return 0; }
  [ -n "$WATCH_TG_CHAT_ID" ] || die "直連 Bot API 要設 WATCH_TG_CHAT_ID"
  mkdir -p "$(dirname "$WATCH_TG_OFFSET_FILE")"
  local offset=0
  [ -f "$WATCH_TG_OFFSET_FILE" ] && offset="$(cat "$WATCH_TG_OFFSET_FILE" 2>/dev/null | tr -cd '0-9')"
  local resp
  resp="$(curl -s --max-time 20 "https://api.telegram.org/bot$WATCH_TG_BOT_TOKEN/getUpdates?offset=$offset&timeout=10")" || { warn "Bot API 連唔上"; return 1; }
  echo "$resp" | WATCH_TG_OFFSET_FILE="$WATCH_TG_OFFSET_FILE" WATCH_OWNER_PATTERN="$WATCH_OWNER_PATTERN" \
  WATCH_TG_CHAT_ID="$WATCH_TG_CHAT_ID" python3 -c "
import json, os, sys
d = json.load(sys.stdin)
mx = int(open(os.environ['WATCH_TG_OFFSET_FILE']).read().strip() or '0') if os.path.exists(os.environ['WATCH_TG_OFFSET_FILE']) else 0
import re
pat = re.compile(os.environ['WATCH_OWNER_PATTERN'], re.I)
hits = 0
for u in d.get('result', []):
    mx = max(mx, int(u.get('update_id', 0)) + 1)
    m = u.get('message', {})
    if str(m.get('chat', {}).get('id', '')) != os.environ['WATCH_TG_CHAT_ID']: continue
    name = str((m.get('from') or {}).get('first_name', '')) + str((m.get('from') or {}).get('username', ''))
    if pat.search(name):
        hits += 1
        print('owner:', str(m.get('text', ''))[:500]); print('--')
open(os.environ['WATCH_TG_OFFSET_FILE'], 'w').write(str(mx))
print('OWNER_HIT %d' % hits)
" | tee "$TMPDIR_WORK/tg.out" | grep -q "OWNER_HIT [1-9]" && warn "TG 有 owner 新訊息" || log "TG 無 owner 新訊息"
  daily_log "TG 快睇 ok"
}

# ---------------- 本地中轉 proxy（chromium 唔識帶 auth 嘅 proxy 先需要） ----------------
start_relay() {
  [ "$WATCH_RELAY_PORT" = "0" ] && return 1
  local upstream="$WATCH_UPSTREAM_PROXY"
  if [ -z "$upstream" ] || [ "$upstream" = "direct" ]; then
    upstream="${https_proxy:-${HTTPS_PROXY:-}}"
  fi
  [ -z "$upstream" ] || [ "$upstream" = "direct" ] && return 1
  # port 已有嘢聽緊就當係現成中轉，直接用（避免重複起）
  if (echo > /dev/tcp/127.0.0.1/$WATCH_RELAY_PORT) 2>/dev/null; then
    log "本地 proxy 中轉已存在：127.0.0.1:$WATCH_RELAY_PORT（沿用）"
    return 0
  fi
  cat > "$TMPDIR_WORK/proxy_relay.py" <<'PYEOF'
import socket, base64, os, sys, threading
from urllib.parse import urlparse
def pipe(a, b):
    try:
        while True:
            d = a.recv(65536)
            if not d: break
            b.sendall(d)
    except OSError: pass
    finally:
        for s in (a, b):
            try: s.shutdown(socket.SHUT_RDWR)
            except OSError: pass
def handle(client, upstream):
    try:
        req = b""
        while b"\r\n\r\n" not in req:
            chunk = client.recv(4096)
            if not chunk: client.close(); return
            req += chunk
        parts = req.split(b"\r\n", 1)[0].decode(errors="replace").split()
        if len(parts) < 2 or parts[0].upper() != "CONNECT":
            client.sendall(b"HTTP/1.1 400 Bad Request\r\n\r\n"); client.close(); return
        target = parts[1]
        p = urlparse(upstream)
        up = socket.create_connection((p.hostname, p.port or 3128), timeout=20)
        auth = ""
        if p.username:
            auth = "Proxy-Authorization: Basic " + base64.b64encode(("%s:%s" % (p.username, p.password or "")).encode()).decode() + "\r\n"
        up.sendall(("CONNECT %s HTTP/1.1\r\nHost: %s\r\n%s\r\n" % (target, target, auth)).encode())
        resp = b""
        while b"\r\n\r\n" not in resp:
            chunk = up.recv(4096)
            if not chunk: break
            resp += chunk
        if b" 200 " not in resp.split(b"\r\n", 1)[0]:
            client.sendall(b"HTTP/1.1 502 Upstream refused\r\n\r\n"); client.close(); up.close(); return
        client.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
        t1 = threading.Thread(target=pipe, args=(client, up), daemon=True)
        t2 = threading.Thread(target=pipe, args=(up, client), daemon=True)
        t1.start(); t2.start(); t1.join(); t2.join()
    except Exception:
        try: client.close()
        except OSError: pass
port = int(sys.argv[1]); upstream = sys.argv[2]
srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", port)); srv.listen(50)
print("relay on 127.0.0.1:%d" % port, flush=True)
while True:
    c, _ = srv.accept()
    threading.Thread(target=handle, args=(c, upstream), daemon=True).start()
PYEOF
  python3 "$TMPDIR_WORK/proxy_relay.py" "$WATCH_RELAY_PORT" "$upstream" > "$TMPDIR_WORK/relay.log" 2>&1 &
  RELAY_PID=$!
  sleep 1
  kill -0 "$RELAY_PID" 2>/dev/null || { warn "本地 proxy 中轉起唔到"; return 1; }
  log "本地 proxy 中轉：127.0.0.1:$WATCH_RELAY_PORT"
  return 0
}

find_chrome() {
  [ -n "$WATCH_CHROME_BIN" ] && [ -x "$WATCH_CHROME_BIN" ] && { echo "$WATCH_CHROME_BIN"; return 0; }
  for c in /opt/meta-chromium/chrome /usr/bin/google-chrome /usr/bin/chromium /usr/bin/chromium-browser; do
    [ -x "$c" ] && { echo "$c"; return 0; }
  done
  return 1
}

# ---------------- 3. Arena 召回檢查 ----------------
step_arena() {
  log "Arena 檢查…"
  python3 -c "import playwright" 2>/dev/null || die "缺 playwright：pip install playwright"
  local chrome; chrome="$(find_chrome)" || die "搵唔到 chromium（設 WATCH_CHROME_BIN）"
  [ -d "$WATCH_ARENA_PROFILE" ] || die "Arena profile 唔存在：$WATCH_ARENA_PROFILE（要登入咗 agent 擁有者 Google 帳戶）"

  local proxy_args=""
  if start_relay; then
    proxy_args="http://127.0.0.1:$WATCH_RELAY_PORT"
  fi

  cat > "$TMPDIR_WORK/arena.py" <<'PYEOF'
import os, sys, json, time
from playwright.sync_api import sync_playwright

URL     = os.environ["WATCH_ARENA_URL"]
PROFILE = os.environ["WATCH_ARENA_PROFILE"]
CHROME  = os.environ["WATCH_CHROME_BIN"]
PHRASE  = os.environ["WATCH_RECALL_PHRASE"]
PROXY   = os.environ.get("WATCH_LOCAL_PROXY", "") or None
DEBUG   = os.environ.get("WATCH_ARENA_DEBUG", "0") == "1"
OUT     = sys.argv[1]
res = {"ok": False, "state": "INIT", "sent": False, "detail": ""}

def shot(pg, name):
    if DEBUG:
        pg.screenshot(path=os.path.join(os.environ["WATCH_TMP"], name))

def btn_texts(pg):
    out = []
    for b in pg.locator("button:visible").all():
        try:
            t = (b.inner_text() or "").strip().replace("\n", " ")
            if t and len(t) < 60: out.append(t)
        except Exception: pass
    return out

def detect(pg):
    """GENERATING / STILL_WORKING / IDLE / LOGGED_OUT / LOAD_ERROR / UNKNOWN"""
    body = (pg.locator("body").inner_text() or "")
    if re_search_login(body, pg): return "LOGGED_OUT", ""
    if "couldn't load this chat" in body or "Something went wrong" in body:
        return "LOAD_ERROR", ""
    # generating: stop button
    if pg.locator("button[aria-label*='stop' i]:visible").count(): return "GENERATING", "stop-button"
    for b in pg.locator("button:visible").all():
        try:
            if (b.inner_text() or "").strip() == "■": return "GENERATING", "black-square"
        except Exception: pass
    import re
    for t in btn_texts(pg):
        if re.search(r"still working|继续工作|繼續工作|仍在工作", t, re.I):
            return "STILL_WORKING", t
    if pg.locator("textarea:visible, div[role='textbox']:visible, [contenteditable='true']:visible").count():
        return "IDLE", ""
    return "UNKNOWN", "buttons=%s" % btn_texts(pg)[:8]

def re_search_login(body, pg):
    import re
    if pg.locator("button:visible", has_text=re.compile(r"^\s*log\s*in\s*$", re.I)).count(): return True
    return False

def find_box(pg):
    for sel in ["textarea:visible", "div[role='textbox']:visible", "[contenteditable='true']:visible"]:
        loc = pg.locator(sel)
        if loc.count(): return loc.first
    return None

def send_phrase(pg):
    box = find_box(pg)
    if not box: return False, "no-textbox"
    try:
        box.click(); pg.wait_for_timeout(500)
        box.fill(PHRASE); pg.wait_for_timeout(500)
        # send: Enter 或 send button
        sent = False
        try:
            box.press("Enter"); sent = True
        except Exception: pass
        if not sent:
            for sel in ["button[aria-label*='send' i]:visible", "button[type='submit']:visible"]:
                if pg.locator(sel).count():
                    pg.locator(sel).first.click(); sent = True; break
        return sent, "sent" if sent else "no-send-button"
    except Exception as e:
        return False, str(e)[:100]


def settle(pg, max_wait=90):
    """2026-10-02 龍壹：arena agent SPA 喺 1c 重載機要 60-90s 先 hydrate 完，
       而 domcontentloaded 因 persistent stream 永不 fire（實測：120s 後 content
       已定喺 165k、renderer 正常，但 load event 仲未嚟，goto domcontentloaded
       永遠 timeout）。改用 commit＋content 長度穩定＋關鍵標記判斷「載入完成」，
       唔再靠 domcontentloaded。"""
    last = -1; stable = 0; waited = 0
    while waited < max_wait:
        try:
            c = pg.content()
        except Exception:
            pg.wait_for_timeout(5000); waited += 5; continue
        n = len(c); cl = c.lower()
        if ('aria-label="stop"' in cl or "aria-label='stop'" in cl
                or "<textarea" in cl or ">■<" in c
                or "was this task successful" in cl):
            return
        if n == last:
            stable += 1
            if stable >= 2 and n > 20000: return
        else:
            stable = 0
        last = n
        pg.wait_for_timeout(5000); waited += 5

def self_heal_login(pg):
    """logged out 自救：Log in -> Google OAuth 按 Continue"""
    import re
    try:
        pg.locator("button:visible", has_text=re.compile(r"^\s*log\s*in\s*$", re.I)).first.click()
        pg.wait_for_timeout(4000)
        # Google OAuth continue
        cont = pg.locator("button:visible", has_text=re.compile(r"continue|繼續", re.I))
        if cont.count():
            cont.first.click(); pg.wait_for_timeout(6000)
        body = (pg.locator("body").inner_text() or "")
        if re.search(r"verify|驗證|choose an account|選擇帳戶", body, re.I):
            return False, "need-phone-verification"
        pg.goto(URL, wait_until="commit", timeout=60000); settle(pg, 90)
        st, _ = detect(pg)
        return (st != "LOGGED_OUT"), "rechecked:" + st
    except Exception as e:
        return False, str(e)[:100]

with sync_playwright() as pl:
    try:
        ctx = pl.chromium.launch_persistent_context(
            PROFILE, executable_path=CHROME, headless=True,
            proxy={"server": PROXY} if PROXY else None,
            args=["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
                  "--no-first-run", "--no-default-browser-check",
                  "--ignore-certificate-errors"])
        pg = ctx.pages[0] if ctx.pages else ctx.new_page()
        if not pg.url.startswith("https://arena.ai/agent/"):
            pg.goto(URL, wait_until="commit", timeout=90000)
        pg.wait_for_timeout(6000)
        pg.reload(wait_until="commit", timeout=90000); settle(pg, 90)
        shot(pg, "arena-1-loaded.png")

        state, detail = detect(pg)
        if state == "LOAD_ERROR":
            # Try again 一次
            try:
                pg.locator("button:visible", has_text="Try again").first.click(timeout=5000)
                pg.wait_for_timeout(6000)
            except Exception: pg.reload(wait_until="commit", timeout=90000); settle(pg, 90)
            state, detail = detect(pg)
        if state == "LOGGED_OUT":
            ok, d = self_heal_login(pg)
            state = "LOGIN_HEALED" if ok else "LOGIN_FAILED"
            detail = d
            res.update(state=state, detail=detail, ok=ok)
            shot(pg, "arena-2-login.png")
        elif state == "GENERATING":
            res.update(state=state, detail=detail, ok=True)  # 生成中：唔郁
        elif state in ("IDLE", "STILL_WORKING"):
            if state == "STILL_WORKING":
                import re
                b = pg.locator("button:visible", has_text=re.compile(r"still working|继续工作|繼續工作|仍在工作", re.I)).first
                try: b.click(timeout=5000); pg.wait_for_timeout(1500)
                except Exception as e: detail += "|click-fail:" + str(e)[:60]
            ok, d = send_phrase(pg)
            res["sent"] = ok; detail += "|" + d
            # 防 phantom-send：等 15 秒＋reload 再驗證
            verified = False
            for attempt in (1, 2):
                pg.wait_for_timeout(15000)
                pg.reload(wait_until="commit", timeout=90000); settle(pg, 90)
                st2, _ = detect(pg)
                if st2 in ("GENERATING", "STILL_WORKING"):
                    verified = True; state = "SENT_VERIFIED"; break
                # 假送出：textbox 空白＋冇 ■＋冇掣
                box = find_box(pg)
                try: empty = not (box.input_value() if box else "x").strip()
                except Exception: empty = True
                if st2 == "IDLE" and empty and attempt == 1:
                    detail += "|phantom-retry"
                    ok, d = send_phrase(pg); res["sent"] = ok; detail += "|" + d
                    continue
                state = "SENT_UNVERIFIED"; break
            res.update(state=state, detail=detail, ok=verified)
            shot(pg, "arena-3-after-send.png")
        else:
            res.update(state=state, detail=detail, ok=False)
            shot(pg, "arena-9-unknown.png")
        ctx.close()
    except Exception as e:
        res.update(state="EXCEPTION", detail=str(e)[:200], ok=False)
        # 2026-10-02 修復：exception 路徑都要關 browser，唔可以漏（之前每次 EXCEPTION 都漏咗一個
        # chromium，1c 機愈積愈多令 load 衝高，之後全部 goto timeout）
        try: ctx.close()
        except Exception: pass

json.dump(res, open(OUT, "w"), ensure_ascii=False)
print(json.dumps(res, ensure_ascii=False))
PYEOF

  WATCH_TMP="$TMPDIR_WORK" WATCH_CHROME_BIN="$chrome" \
  WATCH_LOCAL_PROXY="$proxy_args" WATCH_ARENA_DEBUG="$ARENA_DEBUG" \
  python3 "$TMPDIR_WORK/arena.py" "$TMPDIR_WORK/arena.json" | tee "$TMPDIR_WORK/arena.out"

  python3 - "$TMPDIR_WORK/arena.json" <<'PYEOF'
import json, sys
r = json.load(open(sys.argv[1]))
st = r.get("state", "?")
print("Arena 狀態：%s（已發送=%s）%s" % (st, r.get("sent"), r.get("detail", "")))
sys.exit(0 if r.get("ok") else 3)
PYEOF
  [ "$?" = "3" ] && warn "Arena 要人睇（見上）"
  daily_log "Arena 檢查：$(python3 -c "import json;print(json.load(open('$TMPDIR_WORK/arena.json'))['state'])")"
}

# ---------------- main ----------------
ONLY=""
for a in "$@"; do
  case "$a" in
    --only-relay) ONLY="relay" ;;
    --only-tg)    ONLY="tg" ;;
    --only-arena) ONLY="arena" ;;
    --no-arena)   ONLY="noarena" ;;
    --arena-debug) ARENA_DEBUG=1 ;;
    *) die "未知參數：$a" ;;
  esac
done

case "$ONLY" in
  relay)  step_relay ;;
  tg)     step_tg ;;
  arena)  step_arena ;;
  noarena) step_relay; step_tg ;;
  *)      step_relay; step_tg; step_arena ;;
esac

if [ "$NEED_ATTENTION" = "1" ]; then
  log "完成：有嘢要人睇"
  exit 1
fi
log "完成：一切正常"
exit 0
