#!/usr/bin/env python3
# BASE_TARGET_V094: http://149.118.48.126:8080/persistent/scripts/watcher.py (67,160B | 1,477L | md5=cb11d45f639722650e3ebe156897da51 | sha256=b634251dc94632819cd7e1d69c8b90a2fe9f052a56df11b8b40328a066f111e2 | measured_at=2026-10-06T11:36:00Z UTC)
# BASE_ROOT_8080  : http://149.118.48.126:8080/watcher.py                    (62,239B | 1,379L | md5=797357a7755baa381bee249121e05b3d | sha256=1b89afc9655d19a0b73647ebc8dae36fd8cf442a28196694f8e1c968537f7251 | measured_at=2026-10-06T11:36:00Z UTC)
# SPEC_V3_3_PIN   : watcher-url-rotation-spec-v3.3.txt                       (29,999B | 328L | md5=086d1debba34ca92b1640ef7d6177326 | sha256=43746d5fd5245f47fddba4b9c9ad8df32cfdeb63b3e9ac06490abfbd00d048b3 | measured_at=2026-10-06T11:36:00Z UTC)
"""
A92 (A928584) — watcher.py URL 輪換 + Zoom 100% 硬鎖 + _seat_window_id / enforce_window_geometry (W1-W5) v5.8 (Spec v3.3 Conformance)
整合：
1) 主持 47526 + A4 47528/47530（統一 `_seat_window_id(sid)` + `enforce_window_geometry` W1–W5）：
   - 徹底刪除舊 `_get_browser_window_id` 嘅 `xdotool search --name Chrome` + `best_area` 面積啟發式，
     以及舊 `enforce_window_geometry` 嘅 `xdotool search --onlyvisible --name Arena` 全屏亂掃。
   - 統一由 `_seat_window_id(sid, page=None, root_pid=None)` 按本席 `os.getpid()` 子進程樹 (`--pid`) + 可選 `ZOOMLOCK_<sid>` 標題雙重確認認窗，
     並統一替換 `enforce_window_geometry`、`_send_windowactivate_ctrl0`、`do_send_cycle`、`ca_checkbox_gate` 全部 call sites。
   - W1–W5：`windowmove (0, _slot_target_y(sid))` + `windowsize 1408x900` 後必讀回雙重驗；重開/導航後及每 cycle 重套；
     已處於 `(0, target_y) 1408x900` 時 CYCLE 零動作零 spam；`_slot_target_y(sid)` 兼容 `SLOT_Y_POS` 與 `seats.conf` 順序推算。
2) A3 47518/47527（`SometimeHedley.py` 12/12 + `PackinPhrase.py` 13/13 Z3a+Z3b 全 PASS `rc=0`）。
3) 主持 47578/47580 + A3 47579/47582/47585/47586/47588 + A4 47581/47583/47584/47587/47589/47590（Regime B 兩處 launch_persistent_context 釘死 device_scale_factor=1 與 --force-device-scale-factor=1，刪除 CDP 覆寫對，加冪等 CSS zoom reset、operator 簽認 DPR 基準比值、LOGS_DIR.mkdir 防護、B1 UTC 時間戳與 Spec v3.3 sha256 綁定）。
"""
import difflib, fcntl, hashlib, importlib.util, json, os, py_compile, re, shutil, subprocess, sys, time
from pathlib import Path

HELPER_BLOCK = """
# === URL 輪換 + Zoom 100% 硬鎖 + _seat_window_id / W1-W5 視窗定位 v5.8 (Spec v3.3 Conformance) ===
# BASE_TARGET_V094: http://149.118.48.126:8080/persistent/scripts/watcher.py (67,160B | 1,477L | md5=cb11d45f639722650e3ebe156897da51 | sha256=b634251dc94632819cd7e1d69c8b90a2fe9f052a56df11b8b40328a066f111e2 | measured_at=2026-10-06T11:36:00Z UTC)
# BASE_ROOT_8080  : http://149.118.48.126:8080/watcher.py                    (62,239B | 1,379L | md5=797357a7755baa381bee249121e05b3d | sha256=1b89afc9655d19a0b73647ebc8dae36fd8cf442a28196694f8e1c968537f7251 | measured_at=2026-10-06T11:36:00Z UTC)
# SPEC_V3_3_PIN   : watcher-url-rotation-spec-v3.3.txt                       (29,999B | 328L | md5=086d1debba34ca92b1640ef7d6177326 | sha256=43746d5fd5245f47fddba4b9c9ad8df32cfdeb63b3e9ac06490abfbd00d048b3 | measured_at=2026-10-06T11:36:00Z UTC)
import fcntl, json, os, re, shutil, subprocess, time, urllib.request, urllib.error
ROTATION_TAILS = {"A2": "2", "A3": "3", "A4": "4"}
ROTATION_SERVER_URL_TEMPLATE = "http://149.118.48.126:8080/url-rotation/url-rotation-tail{tail}.json"
ROTATION_HTTP_TIMEOUT_S = 3
_DEFAULT_SYSTEM_DIR = SYSTEM_DIR
TARGET_VIEWPORT_W = 1400
TARGET_VIEWPORT_H = 900
EXPECTED_WIN_W = 1408
EXPECTED_WIN_H = 900
_LAST_VALID_ENTRIES = {}
_LAST_VALID_INDEX = {}
_LAST_VALID_LABEL = {}
_PREFS_CLEANED_AT_BOOT = set()
_SEAT_DPR_BASELINE = {"A2": 1.0, "A3": 1.0, "A4": 1.0}
_BASELINE_LOGGED = set()

def _seats_fallback_url(sid):
    try:
        sconf = SYSTEM_DIR / "seats.conf"
        if sconf.exists():
            for line in sconf.read_text(encoding="utf-8", errors="ignore").splitlines():
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                parts = line.split()
                if len(parts) >= 4 and parts[0] == sid:
                    return parts[3]
    except Exception:
        pass
    return ""

def _slot_target_y(sid):
    \"\"\"由 SLOT_Y_POS 或 seats.conf 席位順序（三個一組 0/900/1800）決定目標 Y 座標。\"\"\"
    if "SLOT_Y_POS" in globals() and sid in SLOT_Y_POS:
        return int(SLOT_Y_POS[sid])
    try:
        sconf = SYSTEM_DIR / "seats.conf"
        if sconf.exists():
            seat_ids = []
            for line in sconf.read_text(encoding="utf-8", errors="ignore").splitlines():
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                parts = line.split()
                if parts:
                    seat_ids.append(parts[0])
            if sid in seat_ids:
                return (seat_ids.index(sid) % 3) * 900
    except Exception:
        pass
    m = re.match(r"^A(\\d+)$", str(sid or ""))
    if m:
        return ((int(m.group(1)) - 2) % 3) * 900
    return 0

def _save_corrupt_copy(bad_path):
    try:
        ts_utc = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
        bak = bad_path.with_name(f"{bad_path.name}.corrupt-{ts_utc}")
        if not bak.exists():
            shutil.copy2(bad_path, bak)
        return bak.name
    except Exception:
        return f"{bad_path.name}.corrupt-err"

def _fetch_server_tail_entries(sid, tail):
    # Tier 1 (主持 47600)：先經 HTTP 讀 http://149.118.48.126:8080/url-rotation/url-rotation-tail{tail}.json（server 唯一本）
    if SYSTEM_DIR != _DEFAULT_SYSTEM_DIR:
        return None
    srv_url = ROTATION_SERVER_URL_TEMPLATE.format(tail=tail)
    try:
        req = urllib.request.Request(srv_url, headers={"Cache-Control": "no-cache"})
        with urllib.request.urlopen(req, timeout=ROTATION_HTTP_TIMEOUT_S) as resp:
            if getattr(resp, "status", 200) != 200:
                log(sid, f"⚠️ [URL-ROT] [HTTP_FALLBACK] Server HTTP {getattr(resp, 'status', None)} ({srv_url})，fallback 本機 JSON")
                return None
            raw_text = resp.read().decode("utf-8")
        data = json.loads(raw_text)
        urls = data.get("urls") if isinstance(data, dict) else None
        if not isinstance(urls, list) or len(urls) == 0:
            log(sid, f"⚠️ [URL-ROT] [HTTP_FALLBACK] {srv_url} urls 為空或格式錯誤，fallback 本機 JSON")
            return None
        valid = [
            {"label": str(item.get("label", "")).strip(), "url": str(item.get("url", "")).strip()}
            for item in urls
            if isinstance(item, dict) and str(item.get("url", "")).strip()
        ]
        if not valid:
            log(sid, f"⚠️ [URL-ROT] [HTTP_FALLBACK] {srv_url} urls 無合法條目，fallback 本機 JSON")
            return None
        _LAST_VALID_ENTRIES[sid] = valid
        log(sid, f"ℹ️ [URL-ROT] [SERVER_JSON] 讀自 server 唯一本 {srv_url} ({len(valid)} 條)")
        return valid
    except Exception as e:
        log(sid, f"⚠️ [URL-ROT] [HTTP_FALLBACK] 讀取 server 唯一本 {srv_url} 失敗 ({type(e).__name__}: {e})，fallback 本機 JSON")
        return None

def _parse_tail_entries(sid, tail):
    srv_valid = _fetch_server_tail_entries(sid, tail)
    if srv_valid is not None:
        return srv_valid, "json"
    jpath = SYSTEM_DIR / f"url-rotation-tail{tail}.json"
    if not jpath.exists():
        log(sid, f"⚠️ [URL-ROT] [JSON_MISSING] {jpath.name} urls 設定檔唔存在 (missing)")
        missing_reason = "missing"
        return None, missing_reason
    try:
        data = json.loads(jpath.read_text(encoding="utf-8"))
        urls = data.get("urls")
        if not isinstance(urls, list) or len(urls) == 0:
            log(sid, f"⚠️ [URL-ROT] [JSON_CORRUPT] {jpath.name} urls 為空或格式錯誤")
            return _LAST_VALID_ENTRIES.get(sid), "corrupt"
        valid = [
            {"label": str(item.get("label", "")).strip(), "url": str(item.get("url", "")).strip()}
            for item in urls
            if isinstance(item, dict) and str(item.get("url", "")).strip()
        ]
        if not valid:
            log(sid, f"⚠️ [URL-ROT] [JSON_CORRUPT] {jpath.name} urls 無合法條目")
            return _LAST_VALID_ENTRIES.get(sid), "corrupt"
        _LAST_VALID_ENTRIES[sid] = valid
        return valid, "json"
    except json.JSONDecodeError as e:
        log(sid, f"⚠️ [URL-ROT] [JSON_CORRUPT] {jpath.name} JSON 語法錯誤／Torn Read ({e})，Fail-Closed 保留快取")
        return _LAST_VALID_ENTRIES.get(sid), "corrupt"
    except Exception as e:
        log(sid, f"⚠️ [URL-ROT] [JSON_CORRUPT] {jpath.name} 讀取異常 ({type(e).__name__}: {e})")
        return _LAST_VALID_ENTRIES.get(sid), "corrupt"

def _state_read_idx(sid, total):
    st_file = SYSTEM_DIR / "url-rotation.json"
    tail = ROTATION_TAILS.get(sid, sid[-1] if sid else "")
    per_tail = SYSTEM_DIR / f"url-rotation-tail{tail}.state.json"
    if not st_file.exists() and not per_tail.exists():
        _LAST_VALID_INDEX.pop(sid, None)
        log(sid, "ℹ️ [URL-ROT] [INDEX_INIT] 狀態檔唔存在（首次初始化），由 index=0 起行")
        return 0
    if st_file.exists():
        try:
            obj = json.loads(st_file.read_text(encoding="utf-8"))
            if isinstance(obj, dict):
                if sid in obj:
                    v = obj[sid]
                    idx = v.get("index") if isinstance(v, dict) else v
                else:
                    idx = obj.get("index")
                if isinstance(idx, int) and not isinstance(idx, bool) and 0 <= idx < total:
                    _LAST_VALID_INDEX[sid] = idx
                    return idx
                raise ValueError(f"out-of-bounds or invalid index={idx!r} (total={total})")
            raise ValueError("url-rotation.json root not dict")
        except Exception as e:
            bak_name = _save_corrupt_copy(st_file)
            log(sid, f"⚠️ [URL-ROT] [INDEX_CORRUPT] url-rotation.json 損毀或越界 ({type(e).__name__}: {e})，已存化驗副本 {bak_name}，嚴守 §四 鐵律保持原 index={_LAST_VALID_INDEX.get(sid)}（永不覆寫壞檔、嚴禁歸零）")
            if sid in _LAST_VALID_INDEX:
                return _LAST_VALID_INDEX[sid]
    if per_tail.exists():
        try:
            obj = json.loads(per_tail.read_text(encoding="utf-8"))
            idx = obj.get("index") if isinstance(obj, dict) else None
            if isinstance(idx, int) and not isinstance(idx, bool) and 0 <= idx < total:
                _LAST_VALID_INDEX[sid] = idx
                return idx
            raise ValueError(f"out-of-bounds or invalid per_tail index={idx!r} (total={total})")
        except Exception as e:
            bak_name = _save_corrupt_copy(per_tail)
            log(sid, f"⚠️ [URL-ROT] [INDEX_CORRUPT] {per_tail.name} 損毀或越界 ({type(e).__name__}: {e})，已存化驗副本 {bak_name}，嚴守 §四 鐵律保持原 index={_LAST_VALID_INDEX.get(sid)}（永不覆寫壞檔、嚴禁歸零）")
    if sid in _LAST_VALID_INDEX:
        return _LAST_VALID_INDEX[sid]
    return None

def _state_write_idx(sid, idx, label="", url=""):
    idx = int(idx)
    _LAST_VALID_INDEX[sid] = idx
    if label:
        _LAST_VALID_LABEL[sid] = label
    tail = ROTATION_TAILS.get(sid, sid[-1] if sid else "")
    per_tail = SYSTEM_DIR / f"url-rotation-tail{tail}.state.json"
    st_file = SYSTEM_DIR / "url-rotation.json"
    try:
        payload = {"seat": sid, "index": idx, "label": label, "url": url, "updated_at": int(time.time())}
        tmp_s = per_tail.with_suffix(".tmp")
        tmp_s.write_text(json.dumps(payload, ensure_ascii=False) + "\\n", encoding="utf-8")
        tmp_s.replace(per_tail)
    except Exception:
        pass
    try:
        state = {"index": idx}
        if st_file.exists():
            try:
                loaded = json.loads(st_file.read_text(encoding="utf-8"))
                if isinstance(loaded, dict):
                    state = loaded
            except Exception:
                pass
        state["index"] = idx
        state[sid] = idx
        tmp_f = st_file.with_suffix(".json.tmp")
        tmp_f.write_text(json.dumps(state, ensure_ascii=False) + "\\n", encoding="utf-8")
        tmp_f.replace(st_file)
    except Exception:
        pass

def load_rotation_entries(sid):
    # 主持 47600 同名入口：1. 先經 HTTP 讀 server 唯一本 -> 2. fallback 本機 JSON -> 3. fallback seats.conf
    return load_url_rotation(sid)

def load_url_rotation(sid):
    \"\"\"Seam 1 (R2/R3/R4/R7)：每次攞 URL 重讀 JSON；回傳 {rotation,label,url,index,count,source}。\"\"\"
    tail = ROTATION_TAILS.get(sid)
    if not tail:
        return {"rotation": False, "label": sid, "url": _seats_fallback_url(sid), "index": 0, "count": 0, "source": "ineligible"}
    entries, _ = _parse_tail_entries(sid, tail)
    if not entries:
        log(sid, f"⚠️ [URL-ROT] [FALLBACK-START:tail{tail}] 退回 seats.conf 預設 URL")
        return {"rotation": True, "label": sid, "url": _seats_fallback_url(sid), "index": -1, "count": 0, "source": "fallback"}
    idx = _state_read_idx(sid, len(entries))
    if idx is None:
        log(sid, f"⚠️ [URL-ROT] [INDEX_CORRUPT] [FALLBACK-START:tail{tail}] 重啟後狀態檔損毀且無記憶體 index，嚴禁歸零或覆寫壞檔，退回 seats.conf")
        return {"rotation": True, "label": sid, "url": _seats_fallback_url(sid), "index": -1, "count": 0, "source": "fallback"}
    chosen = entries[idx]
    prev_lbl = _LAST_VALID_LABEL.get(sid)
    if prev_lbl and prev_lbl != chosen["label"]:
        log(sid, f"⚠️ [URL-ROT] [INDEX_LABEL_MISMATCH] index={idx} 標籤由 {prev_lbl} 變為 {chosen['label']}（只准 append 唔准重排）")
    _LAST_VALID_LABEL[sid] = chosen["label"]
    log(sid, f"URL 輪換：用第 {idx + 1}/{len(entries)} 條（{chosen['label']}） {chosen['url']}")
    return {"rotation": True, "label": chosen["label"], "url": chosen["url"], "index": idx, "count": len(entries), "source": "json"}

def rotation_advance(sid):
    \"\"\"Seam 2 (R5/R6)：耗盡時嚴格只向前 +1（10->1 wrap），persist 狀態，永不後退。\"\"\"
    tail = ROTATION_TAILS.get(sid)
    if not tail:
        return {"rotation": False, "label": sid, "url": _seats_fallback_url(sid), "index": 0, "count": 0, "source": "ineligible"}
    entries, _ = _parse_tail_entries(sid, tail)
    if not entries:
        log(sid, f"⚠️ [URL-ROT] [FALLBACK-START:tail{tail}] 退回 seats.conf 預設 URL")
        return {"rotation": True, "label": sid, "url": _seats_fallback_url(sid), "index": -1, "count": 0, "source": "fallback"}
    cur_idx = _state_read_idx(sid, len(entries))
    if cur_idx is None:
        log(sid, f"⚠️ [URL-ROT] [INDEX_CORRUPT] [FALLBACK-START:tail{tail}] 重啟後狀態檔損毀且無記憶體 index，嚴禁歸零或覆寫壞檔，退回 seats.conf")
        return {"rotation": True, "label": sid, "url": _seats_fallback_url(sid), "index": -1, "count": 0, "source": "fallback"}
    next_idx = (cur_idx + 1) % len(entries)
    chosen = entries[next_idx]
    _state_write_idx(sid, next_idx, chosen["label"], chosen["url"])
    log(sid, f"URL 輪換：用第 {next_idx + 1}/{len(entries)} 條（{chosen['label']}） {chosen['url']}")
    return {"rotation": True, "label": chosen["label"], "url": chosen["url"], "index": next_idx, "count": len(entries), "source": "json"}
def _get_descendant_chrome_pids(root_pid=None):
    \"\"\"由本席 watcher PID 向下遍歷進程樹，搵出所有子孫 PID（優先排列 chrome/chromium 進程）。\"\"\"
    root_pid = int(root_pid if root_pid is not None else os.getpid())
    children_map = {}
    comm_map = {}
    proc_root = Path("/proc")
    if proc_root.exists():
        for entry in proc_root.iterdir():
            if not entry.name.isdigit():
                continue
            pid_i = int(entry.name)
            try:
                stat_txt = (entry / "stat").read_text(encoding="utf-8", errors="ignore")
                rp = stat_txt.rfind(")")
                if rp != -1:
                    comm = stat_txt[stat_txt.find("(") + 1:rp].lower()
                    rest = stat_txt[rp + 2:].split()
                    ppid_i = int(rest[1])
                    children_map.setdefault(ppid_i, []).append(pid_i)
                    comm_map[pid_i] = comm
            except Exception:
                pass
    try:
        out = subprocess.run(["pgrep", "-P", str(root_pid)], capture_output=True, text=True, timeout=2)
        for line in out.stdout.splitlines():
            if line.strip().isdigit():
                cp = int(line.strip())
                if cp not in children_map.get(root_pid, []):
                    children_map.setdefault(root_pid, []).append(cp)
    except Exception:
        pass
    visited = []
    queue = list(children_map.get(root_pid, []))
    seen = {root_pid}
    while queue:
        cur = queue.pop(0)
        if cur in seen:
            continue
        seen.add(cur)
        visited.append(cur)
        queue.extend(children_map.get(cur, []))
    chrome_pids = [p for p in visited if "chrom" in comm_map.get(p, "")]
    other_pids = [p for p in visited if "chrom" not in comm_map.get(p, "")]
    return chrome_pids + other_pids + [root_pid]

def _parse_window_geom(geom_text):
    \"\"\"解析 xdotool getwindowgeometry 輸出，回傳 (x, y, w, h) 或 None。\"\"\"
    if not geom_text:
        return None
    x = y = w = h = None
    m_pos = re.search(r"Position:\\s*(-?\\d+),(-?\\d+)", geom_text)
    if m_pos:
        x, y = int(m_pos.group(1)), int(m_pos.group(2))
    m_geo = re.search(r"Geometry:\\s*(\\d+)x(\\d+)", geom_text)
    if m_geo:
        w, h = int(m_geo.group(1)), int(m_geo.group(2))
    if x is None:
        mx = re.search(r"^X=(-?\\d+)", geom_text, re.M)
        my = re.search(r"^Y=(-?\\d+)", geom_text, re.M)
        if mx and my:
            x, y = int(mx.group(1)), int(my.group(1))
    if w is None:
        mw = re.search(r"^WIDTH=(\\d+)", geom_text, re.M)
        mh = re.search(r"^HEIGHT=(\\d+)", geom_text, re.M)
        if mw and mh:
            w, h = int(mw.group(1)), int(mh.group(1))
    if x is not None and y is not None and w is not None and h is not None:
        return (x, y, w, h)
    return None

def _descendant_x11_wids(root_pid=None):
    \"\"\"按本席 PID 樹調用 xdotool search --onlyvisible --pid <cpid> 認返自己個 Chrome window（嚴禁全屏見 Arena 就郁）。\"\"\"
    env = {**os.environ, "DISPLAY": ":99"}
    cand_pids = _get_descendant_chrome_pids(root_pid)
    found_wids = []
    for cpid in cand_pids:
        try:
            res = subprocess.run(
                ["xdotool", "search", "--onlyvisible", "--pid", str(cpid)],
                capture_output=True, text=True, timeout=3, env=env
            )
            for w in res.stdout.strip().splitlines():
                w = w.strip()
                if w and w not in found_wids:
                    found_wids.append(w)
        except Exception:
            pass
    return found_wids

def _seat_window_id(sid=None, page=None, root_pid=None):
    \"\"\"統一本席視窗解析器（主持 47526 + A4 47528【2】）：只准由本席 PID 樹 (--pid) 或本席 page ZOOMLOCK 臨時標題認窗，徹底廢除面積啟發式。\"\"\"
    wids = _descendant_x11_wids(root_pid=root_pid)
    if wids:
        return wids[-1]
    if page is not None and sid:
        env = {**os.environ, "DISPLAY": ":99"}
        tag = f"ZOOMLOCK_{sid}_{int(time.time()*1000)%100000}"
        try:
            old_t = page.evaluate(f"() => {{ const t = document.title; document.title = '{tag}'; return t; }}")
            time.sleep(0.08)
            out = subprocess.run(["xdotool", "search", "--onlyvisible", "--name", tag], env=env, timeout=3, capture_output=True, text=True)
            twids = [w.strip() for w in out.stdout.splitlines() if w.strip()]
            page.evaluate("(t) => { document.title = t; }", old_t)
            if twids:
                return twids[-1]
        except Exception:
            pass
    return None


def _get_seat_dpr_baseline(sid):
    '''讀取 operator 簽認或 seats.conf 宣告之 DPR 基準（永不自動學習未驗證讀數）；回傳 float 或 None。'''
    try:
        sconf = SYSTEM_DIR / "seats.conf"
        if sconf.exists():
            for line in sconf.read_text(encoding="utf-8", errors="ignore").splitlines():
                s = line.strip()
                m = re.match(r"^#\\s*zoom_baseline\\s*:\\s*(A\\d+)\\s*=\\s*([0-9.]+)", s)
                if m and m.group(1) == sid:
                    val = float(m.group(2))
                    if val > 0:
                        _SEAT_DPR_BASELINE[sid] = val
                        if sid not in _BASELINE_LOGGED:
                            _BASELINE_LOGGED.add(sid)
                            ts_utc = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
                            log(sid, f"[ZOOM-BASELINE:{sid}={val}@{ts_utc}]")
                        return val
    except Exception:
        pass
    val = _SEAT_DPR_BASELINE.get(sid, 1.0)
    try:
        val = float(val)
        return val if val > 0 else None
    except Exception:
        return None

def _sanitize_profile_zoom_prefs(profile_dir, sid):
    if not profile_dir:
        return False
    prefs_path = Path(profile_dir) / "Default" / "Preferences"
    if not prefs_path.exists():
        return False
    try:
        data = json.loads(prefs_path.read_text(encoding="utf-8", errors="ignore"))
        if not isinstance(data, dict):
            return False
        changed = False
        part = data.get("partition")
        if isinstance(part, dict):
            for k in ("per_host_zoom_levels", "partition_default_zoom_levels"):
                if part.get(k):
                    log(sid, f"🔍 [ZOOM-PREFS] 捉到 Preferences partition.{k}={part[k]}，即刻清零")
                    part[k] = {}
                    changed = True
            if "default_zoom_level" in part and part.get("default_zoom_level") not in (None, 0, 0.0, {}):
                log(sid, f"🔍 [ZOOM-PREFS] 捉到 Preferences partition.default_zoom_level={part.get('default_zoom_level')}，即刻移除")
                part.pop("default_zoom_level", None)
                changed = True
        for k in ("per_host_zoom_levels", "partition_default_zoom_levels"):
            if data.get(k):
                log(sid, f"🔍 [ZOOM-PREFS] 捉到 Preferences {k}={data[k]}，即刻清零")
                data[k] = {}
                changed = True
        if "default_zoom_level" in data and data.get("default_zoom_level") not in (None, 0, 0.0, {}):
            log(sid, f"🔍 [ZOOM-PREFS] 捉到 Preferences default_zoom_level={data.get('default_zoom_level')}，即刻移除")
            data.pop("default_zoom_level", None)
            changed = True
        if changed:
            tmp_p = prefs_path.with_suffix(".tmp")
            tmp_p.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
            tmp_p.replace(prefs_path)
            _PREFS_CLEANED_AT_BOOT.add(sid)
        return changed
    except Exception as e:
        log(sid, f"⚠️ [ZOOM-PREFS] 清理 Preferences 異常 ({type(e).__name__}: {e})")
        return False

def _apply_css_zoom_reset(page, sid):
    '''冪等 CSS zoom reset（A4 47581【2】）：若 documentElement.style.zoom 已為空/'100%'/'1' 則零動作；否則重設為 '100%'。'''
    try:
        cur_z = page.evaluate("document.documentElement && document.documentElement.style ? document.documentElement.style.zoom : ''")
        if isinstance(cur_z, str) and cur_z.strip() not in ("", "100%", "1"):
            page.evaluate("if (document.documentElement && document.documentElement.style) { document.documentElement.style.zoom = '100%'; }")
            return True
        return False
    except Exception as e:
        log(sid, f"⚠️ [ZOOM-CSS] CSS zoom reset 異常 ({type(e).__name__}: {e})")
        return False

def _send_windowactivate_ctrl0(page, sid):
    try:
        if hasattr(page, "keyboard") and hasattr(page.keyboard, "press"):
            page.keyboard.press("Control+0")
    except Exception as e:
        log(sid, f"⚠️ [ZOOM-KEY] Playwright Control+0 異常 ({type(e).__name__}: {e})")
    if os.environ.get("DISPLAY") or Path("/tmp/.X11-unix/X99").exists():
        try:
            env = {**os.environ, "DISPLAY": ":99"}
            wid = _seat_window_id(sid, page=page)
            if wid:
                subprocess.run(["xdotool", "windowraise", str(wid)], env=env, timeout=3, capture_output=True)
                time.sleep(0.12)
                subprocess.run(["xdotool", "windowactivate", "--sync", str(wid)], env=env, timeout=3, capture_output=True)
                time.sleep(0.12)
                subprocess.run(["xdotool", "key", "ctrl+0"], env=env, timeout=3, capture_output=True)
        except Exception as e:
            log(sid, f"⚠️ [ZOOM-X11] xdotool ctrl+0 異常 ({type(e).__name__}: {e})")

def ensure_zoom_100(page, sid, stage="CYCLE", profile_dir=None):
    '''Seam 3 (Z1-Z6)：四源讀回驗證（含 operator 基準比值 dpr/baseline）+ 3步遞進校正 -> 失敗 HOLD 禁座標點擊。'''
    base_dpr = _get_seat_dpr_baseline(sid)

    def _probe():
        if base_dpr is None or base_dpr <= 0:
            return {"dpr": 0.0, "dprRatio": 0.0, "innerW": 0, "innerH": 0, "vvScale": 0.0, "unconfirmed": True}
        try:
            raw_dpr = page.evaluate("window.devicePixelRatio")
            raw_w = page.evaluate("window.innerWidth")
            raw_h = page.evaluate("window.innerHeight")
            vv_raw = page.evaluate("JSON.stringify({scale: (window.visualViewport && window.visualViewport.scale) || 1.0})")
            dpr = float(raw_dpr if raw_dpr is not None else 1.0)
            w = int(raw_w if raw_w is not None else TARGET_VIEWPORT_W)
            h = int(raw_h if raw_h is not None else TARGET_VIEWPORT_H)
            if isinstance(vv_raw, str):
                vv_scale = float(json.loads(vv_raw).get("scale", 1.0))
            elif isinstance(vv_raw, (int, float)):
                vv_scale = float(vv_raw)
            else:
                vv_scale = 1.0
            ratio = round(dpr / base_dpr, 4)
            return {"dpr": dpr, "dprRatio": ratio, "innerW": w, "innerH": h, "vvScale": vv_scale, "unconfirmed": False}
        except Exception as e:
            log(sid, f"⚠️ [ZOOM-PROBE] 縮放探針讀取失敗 ({type(e).__name__}: {e}) -> UNCONFIRMED")
            return {"dpr": 0.0, "dprRatio": 0.0, "innerW": 0, "innerH": 0, "vvScale": 0.0, "unconfirmed": True}

    def _ok(m):
        return (
            not m.get("unconfirmed", False)
            and abs(float(m.get("dprRatio", m.get("dpr", 0))) - 1.0) < 0.01
            and int(m.get("innerW", 0)) == TARGET_VIEWPORT_W
            and int(m.get("innerH", 0)) == TARGET_VIEWPORT_H
            and abs(float(m.get("vvScale", 1.0)) - 1.0) < 0.01
        )

    def _z5(m, att, meth, res):
        w = int(m.get("innerW", 0))
        pz = round(TARGET_VIEWPORT_W / w, 4) if w > 0 else 0.0
        return f"zoom dpr={m.get('dpr')} ratio={m.get('dprRatio', m.get('dpr'))} innerW={w} innerH={m.get('innerH')} vvScale={m.get('vvScale', 1.0)} pageZoom={pz} attempt={att} method={meth} result={res}"

    m0 = _probe()
    if _ok(m0):
        _PREFS_CLEANED_AT_BOOT.discard(sid)
        if stage != "CYCLE":
            log(sid, _z5(m0, 0, "noop", "OK"))
        return True, m0

    ts_drift_utc = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    log(sid, f"🚨 ERROR [ZOOM-LOCK] [ZOOM-DRIFT-ALERT:{sid}@{ts_drift_utc}] 偵測到 Zoom/Scale 偏離 100% (stage={stage} baseline={base_dpr} dpr={m0.get('dpr')} ratio={m0.get('dprRatio')} innerW={m0.get('innerW')} innerH={m0.get('innerH')} vvScale={m0.get('vvScale')} unconfirmed={m0.get('unconfirmed')})，拒絕靜默修復，立即報錯並啟動三步強制校正")
    if sid in _PREFS_CLEANED_AT_BOOT or stage == "TARGET-URL":
        log(sid, f"⚠️ [CAUSAL-NOTE:PREFS-NOT-PERSISTED] {sid} 啟動清 Preferences 後首輪仍測得非 100% ({m0})，進入三步強制校正")
        _PREFS_CLEANED_AT_BOOT.discard(sid)

    # Attempt 1: 鎖定本席 PID 視窗 (_seat_window_id) + windowactivate --sync + ctrl+0
    _send_windowactivate_ctrl0(page, sid)
    m1 = _probe()
    if _ok(m1):
        log(sid, _z5(m1, 1, "windowactivate+ctrl+0", "OK"))
        return True, m1
    log(sid, _z5(m1, 1, "windowactivate+ctrl+0", "FAIL"))

    # Attempt 2: 清 Preferences + windowactivate --sync + ctrl+0
    _sanitize_profile_zoom_prefs(profile_dir, sid)
    _send_windowactivate_ctrl0(page, sid)
    m2 = _probe()
    if _ok(m2):
        log(sid, _z5(m2, 2, "clean_prefs+windowactivate+ctrl+0", "OK"))
        return True, m2
    log(sid, _z5(m2, 2, "clean_prefs+windowactivate+ctrl+0", "FAIL"))

    # Attempt 3: 冪等 CSS zoom reset + windowactivate + ctrl+0, then reread（依主持 47596 真機三席實測，刪去 runtime 視口重設與 CDP 覆寫）
    _apply_css_zoom_reset(page, sid)
    _send_windowactivate_ctrl0(page, sid)
    m3 = _probe()
    if _ok(m3):
        log(sid, _z5(m3, 3, "css_zoom_reset+windowactivate+ctrl+0", "OK"))
        return True, m3

    log(sid, f"🚨 [CAUSAL-NOTE:Z2-HOLD:{stage}] {_z5(m3, 3, 'escalate_skip_click_and_reopen', 'FAIL')} -> HOLD（因 Zoom 未達 100%，本輪嚴禁座標點擊並觸發收 Browser 清 Preferences 重開）")
    return False, m3
"""

LOG_REPLACEMENT = """def log(sid, msg):
    ts = time.strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] [{sid}] {msg}"
    print(line, flush=True)
    try:
        LOGS_DIR.mkdir(parents=True, exist_ok=True)
        logfile = LOGS_DIR / f"watcher-{sid}.log"
        with open(logfile, "a") as f:
            f.write(line + "\\n")
        # trim to 5000 lines（errors=ignore 防亂碼 crash，2026-10-05 修）
        with open(logfile, errors="ignore") as f:
            lines = f.readlines()
        if len(lines) > 5000:
            with open(logfile, "w") as f:
                f.writelines(lines[-5000:])
    except Exception:
        pass
"""

ENFORCE_GEOM_REPLACEMENT = """# 席位→Y 座標映射（Browser 排位鐵律，2026-10-05 19:44 HKT owner 下令必須）
# 三個一組：第一席 y=0，第二席 y=900，第三席 y=1800
SLOT_Y_POS = {
    'A2': 0, 'A3': 900, 'A4': 1800,
    'A5': 0, 'A6': 900, 'A7': 1800,
    'A8': 0, 'A9': 900, 'A10': 1800,
}

def enforce_window_geometry(sid, stage="CYCLE", root_pid=None):
    '''強制本席 browser window 位置與尺寸（主持 47526 + A4 47528 W1-W8：按本席 PID 樹認窗 + windowmove + windowsize 雙重驗）
    1) W2：只准由本席 PID 樹 (`_descendant_x11_wids`) 認返自己個 window（嚴禁見 Arena 就郁晒）。
    2) W1/W3：檢查及校正位置 (0, _slot_target_y(sid)) ＋ 尺寸 1408x900，重讀雙重驗。
    3) W4/W5/W8：重開/導航後及每 cycle 執行；CYCLE 已合規時零動作零 spam。
    '''
    target_x = 0
    target_y = _slot_target_y(sid)
    env = {**os.environ, 'DISPLAY': ':99'}
    try:
        wids = _descendant_x11_wids(root_pid=root_pid)
        if not wids:
            log(sid, f"⚠️ [GEOM-W2] 按本席 PID 樹搵唔到 Chrome window (pid={root_pid or os.getpid()})，跳過呢個 cycle")
            return False
        all_ok = True
        for wid in wids:
            geom_out = subprocess.run(
                ['xdotool', 'getwindowgeometry', str(wid)],
                capture_output=True, text=True, timeout=5, env=env
            ).stdout
            parsed = _parse_window_geom(geom_out)
            pos_ok = parsed is not None and parsed[0] == target_x and parsed[1] == target_y
            size_ok = parsed is not None and parsed[2] == EXPECTED_WIN_W and parsed[3] == EXPECTED_WIN_H
            if pos_ok and size_ok:
                if stage != "CYCLE":
                    log(sid, f"geom wid={wid} x={parsed[0]} y={parsed[1]} w={parsed[2]} h={parsed[3]} attempt=0 result=OK")
                continue
            subprocess.run(
                ['xdotool', 'windowmove', str(wid), str(target_x), str(target_y)],
                timeout=5, env=env, capture_output=True
            )
            subprocess.run(
                ['xdotool', 'windowsize', str(wid), str(EXPECTED_WIN_W), str(EXPECTED_WIN_H)],
                timeout=5, env=env, capture_output=True
            )
            time.sleep(0.5)
            geom2_out = subprocess.run(
                ['xdotool', 'getwindowgeometry', str(wid)],
                capture_output=True, text=True, timeout=5, env=env
            ).stdout
            parsed2 = _parse_window_geom(geom2_out)
            pos_ok2 = parsed2 is not None and parsed2[0] == target_x and parsed2[1] == target_y
            size_ok2 = parsed2 is not None and parsed2[2] == EXPECTED_WIN_W and parsed2[3] == EXPECTED_WIN_H
            if not (pos_ok2 and size_ok2):
                log(sid, f"🚨 [GEOM-W1] window {wid} 較唔返 ({target_x},{target_y}) {EXPECTED_WIN_W}x{EXPECTED_WIN_H}（實測={parsed2}） attempt=1 result=FAIL，呢個 cycle 唔運行")
                all_ok = False
            else:
                log(sid, f"⚠️ [GEOM-W1] window {wid} 已校正至位置 ({target_x},{target_y}) 及尺寸 {EXPECTED_WIN_W}x{EXPECTED_WIN_H}（實測={parsed2}） attempt=1 result=OK")
        return all_ok
    except Exception as e:
        log(sid, f"enforce_window_geometry 出錯 ({type(e).__name__}: {e})，唔准運行")
        return False
"""

def patch_watcher_source(src: str) -> str:
    if "ROTATION_TAILS =" in src:
        return src
    if src.startswith("#!/usr/bin/env python3\n"):
        src = src.replace(
            "#!/usr/bin/env python3\n",
            "#!/usr/bin/env python3\n"
            "# BASE_TARGET_V094: http://149.118.48.126:8080/persistent/scripts/watcher.py (67,160B | 1,477L | md5=cb11d45f639722650e3ebe156897da51 | sha256=b634251dc94632819cd7e1d69c8b90a2fe9f052a56df11b8b40328a066f111e2 | measured_at=2026-10-06T11:36:00Z UTC)\n"
            "# BASE_ROOT_8080  : http://149.118.48.126:8080/watcher.py                    (62,239B | 1,379L | md5=797357a7755baa381bee249121e05b3d | sha256=1b89afc9655d19a0b73647ebc8dae36fd8cf442a28196694f8e1c968537f7251 | measured_at=2026-10-06T11:36:00Z UTC)\n"
            "# SPEC_V3_3_PIN   : watcher-url-rotation-spec-v3.3.txt                       (29,999B | 328L | md5=086d1debba34ca92b1640ef7d6177326 | sha256=43746d5fd5245f47fddba4b9c9ad8df32cfdeb63b3e9ac06490abfbd00d048b3 | measured_at=2026-10-06T11:36:00Z UTC)\n",
            1
        )
    assert "def load_vpn_ports():" in src, "Anchor 1 missing"
    src = src.replace("def load_vpn_ports():", HELPER_BLOCK.lstrip() + "\ndef load_vpn_ports():", 1)

    # Patch log(sid, msg) to ensure LOGS_DIR.mkdir(parents=True, exist_ok=True)
    m_log = re.search(
        r"def log\(sid, msg\):.*?(?=\n# 席位→Y 座標映射|\ndef is_generating\(page\):)",
        src,
        re.S
    )
    assert m_log, "Anchor log(sid, msg) missing"
    src = src[:m_log.start()] + LOG_REPLACEMENT + "\n" + src[m_log.end():]

    # Replace enforce_window_geometry in v0.94 (or insert before is_generating if patching v0.92)
    m_enf = re.search(
        r"# 席位→Y 座標映射.*?\ndef enforce_window_geometry\(sid\):.*?(?=\ndef is_generating\(page\):)",
        src,
        re.S
    )
    if m_enf:
        src = src[:m_enf.start()] + ENFORCE_GEOM_REPLACEMENT + src[m_enf.end():]
    else:
        src = src.replace("def is_generating(page):", ENFORCE_GEOM_REPLACEMENT + "\ndef is_generating(page):", 1)

    # Completely replace the old largest-area _get_browser_window_id function with PID-tree _seat_window_id
    m_old_wid = re.search(
        r"def _get_browser_window_id\(\):.*?\ndef _read_window_pixel",
        src,
        re.S
    )
    assert m_old_wid, "Anchor _get_browser_window_id missing"
    new_wid_fn = (
        'def _get_browser_window_id(sid=None, page=None):\n'
        '    """兼容轉發至統一本席 PID 樹視窗解析器 _seat_window_id（已徹底移除面積啟發式）"""\n'
        '    return _seat_window_id(sid=sid, page=page)\n\n'
        'def _read_window_pixel'
    )
    src = src[:m_old_wid.start()] + new_wid_fn + src[m_old_wid.end():]

    # Replace the call sites in do_send_cycle and ca_checkbox_gate with _seat_window_id(sid, page=page)
    src = src.replace("wid = _get_browser_window_id()", "wid = _seat_window_id(sid, page=page)")

    gate_anchor = "def ca_checkbox_gate(page, sid, label):"
    assert gate_anchor in src, "Anchor ca_checkbox_gate missing"
    src = src.replace(
        gate_anchor,
        gate_anchor + '\n    _zok, _ = ensure_zoom_100(page, sid, f"GATE-{label}")\n    if not _zok:\n        log(sid, f"🚨 [CAUSAL-NOTE:Z2-HOLD] {label} 因 Zoom 未達 100% 拒絕執行座標點擊")\n        return False',
        1
    )

    anchor2 = '    log(sid, f"啟動 watcher: profile={cfg[\'profile\']}, vpn={cfg[\'vpn\']}:{port}, url={cfg[\'url\']}")'
    assert anchor2 in src, "Anchor 2 missing"
    rep2 = (
        '    _sanitize_profile_zoom_prefs(profile_dir, sid)\n'
        '    _fallback_seat_url = cfg["url"]\n'
        '    if sid in ROTATION_TAILS:\n'
        '        _rot_init = load_url_rotation(sid)\n'
        '        if _rot_init.get("url"):\n'
        '            cfg["url"] = _rot_init["url"]\n'
        + anchor2
    )
    src = src.replace(anchor2, rep2, 1)

    # EDIT 1: Pin device_scale_factor=1 at both launch_persistent_context sites + --force-device-scale-factor=1
    src = src.replace(
        "browser = p.chromium.launch_persistent_context(\n                str(profile_dir),\n                headless=False,",
        "browser = p.chromium.launch_persistent_context(\n                str(profile_dir),\n                headless=False,\n                device_scale_factor=1,",
        1
    )
    src = src.replace(
        "browser = p.chromium.launch_persistent_context(\n                            str(profile_dir), headless=False,",
        "_sanitize_profile_zoom_prefs(profile_dir, sid)\n                        browser = p.chromium.launch_persistent_context(\n                            str(profile_dir), headless=False, device_scale_factor=1,",
        1
    )
    src = src.replace(
        '"--window-size=1408,900",  # 外框1408x900 → content 1400x900',
        '"--force-device-scale-factor=1",\n                                "--high-dpi-support=1",\n                                "--window-size=1408,900",  # 外框1408x900 → content 1400x900'
    )
    assert src.count("device_scale_factor=1") == 2, "Expected 2 launch_persistent_context device_scale_factor=1 sites"
    m_reopen_vp = re.search(
        r"([ \t]*)# 強制 viewport 1400x900（確保座標校準啱）\n[ \t]*try:\n[ \t]*page\.[^\n]+\n[ \t]*except:\n[ \t]*pass",
        src
    )
    if m_reopen_vp:
        ind_rv = m_reopen_vp.group(1)
        src = src[:m_reopen_vp.start()] + f'{ind_rv}enforce_window_geometry(sid, stage="REOPEN")' + src[m_reopen_vp.end():]

    m3 = re.search(r'^([ \t]*)page\.goto\(cfg\["url"\], wait_until="domcontentloaded", timeout=60000\)', src, re.M)
    assert m3, "Anchor 3 (page.goto cfg['url']) missing"
    ind3 = m3.group(1)
    rep3 = (
        f'{ind3}if sid in ROTATION_TAILS:\n'
        f'{ind3}    _rot_cur = load_url_rotation(sid)\n'
        f'{ind3}    if _rot_cur.get("url"):\n'
        f'{ind3}        cfg["url"] = _rot_cur["url"]\n'
        f'{m3.group(0)}\n'
        f'{ind3}enforce_window_geometry(sid, stage="TARGET-URL")\n'
        f'{ind3}ensure_zoom_100(page, sid, "TARGET-URL", profile_dir)'
    )
    src = src[:m3.start()] + rep3 + src[m3.end():]

    m_cycle = re.search(r'^([ \t]*)content = page\.content\(\)', src, re.M)
    assert m_cycle, "Anchor cycle content missing"
    ind_c = m_cycle.group(1)
    rep_c = (
        f'{ind_c}_zok_c, _zmeas_c = ensure_zoom_100(page, sid, "CYCLE", profile_dir)\n'
        f'{ind_c}if not _zok_c:\n'
        f'{ind_c}    log(sid, f"🚨 [CAUSAL-NOTE:Z2-HOLD] 本 cycle 因 Zoom 校唔實 ({{_zmeas_c}}) 跳過所有點擊；即刻收 Browser -> 清磁碟 Preferences -> 下輪重開")\n'
        f'{ind_c}    try:\n'
        f'{ind_c}        browser.close()\n'
        f'{ind_c}    except Exception:\n'
        f'{ind_c}        pass\n'
        f'{ind_c}    browser = None\n'
        f'{ind_c}    _sanitize_profile_zoom_prefs(profile_dir, sid)\n'
        f'{ind_c}    if once:\n'
        f'{ind_c}        break\n'
        f'{ind_c}    time.sleep(CHECK_INTERVAL)\n'
        f'{ind_c}    continue\n'
        f'{m_cycle.group(0)}'
    )
    src = src[:m_cycle.start()] + rep_c + src[m_cycle.end():]

    # Ensure root base (v0.92) also calls enforce_window_geometry(sid) after heartbeat
    if "if not enforce_window_geometry(sid):" not in src:
        m_hb = re.search(r'^([ \t]*)log\(sid, f"心跳: \{heartbeat\}"\)', src, re.M)
        if m_hb:
            ind_h = m_hb.group(1)
            rep_h = (
                f'{m_hb.group(0)}\n\n'
                f'{ind_h}if not enforce_window_geometry(sid):\n'
                f'{ind_h}    log(sid, "🚨 位置/尺寸唔啱，呢個 cycle 跳過，等 30 秒再試")\n'
                f'{ind_h}    time.sleep(CHECK_INTERVAL)\n'
                f'{ind_h}    continue'
            )
            src = src[:m_hb.start()] + rep_h + src[m_hb.end():]

    m4 = re.search(r'^([ \t]*)if is_conversation_too_long\(page, sid\):', src, re.M)
    assert m4, "Anchor 4 (if is_conversation_too_long) missing"
    ind4 = m4.group(1)
    sub = ind4 + "    "
    rep4 = (
        f'{m4.group(0)}\n'
        f'{sub}if sid in ROTATION_TAILS:\n'
        f'{sub}    _rot_nxt = rotation_advance(sid)\n'
        f'{sub}    if _rot_nxt.get("url"):\n'
        f'{sub}        cfg["url"] = _rot_nxt["url"]\n'
        f'{sub}    first_load = True\n'
        f'{sub}    log(sid, f"🚨 對話超長確認：{{sid}} 唔報 Muse，即刻經 first_load 轉去 URL（{{_rot_nxt.get(\'label\')}}: {{cfg[\'url\']}}）")\n'
        f'{sub}    continue'
    )
    src = src[:m4.start()] + rep4 + src[m4.end():]
    return src

if __name__ == "__main__":
    if len(sys.argv) >= 2 and Path(sys.argv[1]).exists():
        target = Path(sys.argv[1])
        orig = target.read_text(encoding="utf-8")
        patched = patch_watcher_source(orig)
        target.write_text(patched, encoding="utf-8")
        py_compile.compile(str(target), doraise=True)
