#!/usr/bin/env python3
"""
kline_probe.py  (merged v8.9.6 - 25s Watchdog Docker重建版)
基礎設施層：物理目錄隔離 / CoW 秒級快照 / 1.0核實體鎖定 (專機專用) / 種子預固化免初始
應用層：ROUND0 → (移除ROUND1) → ROUND2 無限迴圈 (同 Session 無縫連跑)

更新記錄：
  v26. (v8.9) 【黑核心修復】找完資料後先將 T1~T5 倒敘轉為順序。
  v27. (v8.9.2) 【快速崩潰偵測】App崩潰時立即重啟。
  v28. (v8.9.4) 【被動彈窗加強與優雅換場】新增廣告專用ID於被動清理清單首位；美東04:01/20:09換場，保留已收集Tab數據。
  v29. (v8.9.5) 【零延遲彈窗防禦】在每次 do_dump 抓取 XML 後立即做 0.001 秒字串掃描，若發現廣告特徵則秒殺清理並重抓，徹底保障資料乾淨度且不影響正常效能。
  v30. (v8.9.6) 【25s Watchdog Docker重建】Round2任何操作超過25秒未完成，立即重建Docker，從未完成的tab繼續執行。
"""

import uiautomator2 as u2
import subprocess
import json
import os
import re
import time
import requests
import threading
import gc
import zoneinfo
import argparse
import xml.etree.ElementTree as ET
from datetime import datetime
from collections import defaultdict

# ══════════════════════════════════════════
# 基礎設施配置
# ══════════════════════════════════════════
MACHINE_NUM  = 1
DEVICE_ID    = "127.0.0.1:5555"
DOCKER_NAME  = "redroid_1"
DOCKER_PORT  = "5555"

# ══════════════════════════════════════════
# 應用配置
# ══════════════════════════════════════════
PUBLIC_DIR   = os.path.expanduser("~/public")

ROUND0_FILE     = os.path.join(PUBLIC_DIR, "combined_stocks.json")
ROUND2_FILE     = os.path.join(PUBLIC_DIR, "new_klines_1.json")
ROUND2_ALL_FILE = os.path.join(PUBLIC_DIR, "renew_klines_1.json")
OW_FILE         = os.path.join(PUBLIC_DIR, "ow.json")
LOG_FILE        = os.path.join(PUBLIC_DIR, "kline_probe_log.json")

WEBHOOK_URL  = "http://213.35.112.20:8080/api/notify-klines-ready"

os.makedirs(PUBLIC_DIR, exist_ok=True)

# ══════════════════════════════════════════
# ROUND2 Watchdog 配置
# ══════════════════════════════════════════
WATCHDOG_TIMEOUT = 25        # 超過此秒數觸發 Docker 重建
_watchdog_timer  = None      # 當前 watchdog Timer 物件
_watchdog_lock   = threading.Lock()
_watchdog_triggered = threading.Event()  # 被設置代表 watchdog 已觸發，主迴圈需感知

def _watchdog_fire(reason: str):
    """Watchdog 觸發回呼：在獨立執行緒中執行 Docker 重建，並通知主迴圈。"""
    lp(f"\n🚨 [WATCHDOG] 超過 {WATCHDOG_TIMEOUT}s 未完成！原因: {reason}")
    lp(f"🚨 [WATCHDOG] 立即執行 Docker 重建...")
    append_log("WATCHDOG_FIRE", f"Timeout {WATCHDOG_TIMEOUT}s reason={reason}")
    _watchdog_triggered.set()   # 通知主迴圈
    try:
        docker_recovery()
        if wait_device_ready(timeout=120, check_interval=2):
            lp("✅ [WATCHDOG] Docker 重建完成，主迴圈將重連並繼續。")
        else:
            lp("❌ [WATCHDOG] Docker 重建後設備未就緒，主迴圈將自行處理。")
    except Exception as e:
        lp(f"❌ [WATCHDOG] Docker 重建異常: {e}")

def watchdog_reset(reason: str = ""):
    """
    重置（餵狗）：取消上一個 timer，啟動新的 25s timer。
    在每次 switch_tab / do_dump 開始前呼叫。
    """
    global _watchdog_timer
    with _watchdog_lock:
        if _watchdog_timer is not None:
            _watchdog_timer.cancel()
            _watchdog_timer = None
        t = threading.Timer(WATCHDOG_TIMEOUT, _watchdog_fire, args=(reason,))
        t.daemon = True
        t.start()
        _watchdog_timer = t

def watchdog_stop():
    """
    停止 watchdog（操作完成後呼叫）。
    ★ v8.10 修正：不清 _watchdog_triggered！
    該 event 只能由 _reconnect_after_watchdog() 在「重連完成後」清除。
    否則 switch_tab 拋 RuntimeError 時 finally 會銷毀觸發證據，主迴圈漏判。
    """
    global _watchdog_timer
    with _watchdog_lock:
        if _watchdog_timer is not None:
            _watchdog_timer.cancel()
            _watchdog_timer = None

# ══════════════════════════════════════════
# ROUND0 Cache 策略
#   - combined_stocks.json      ：對外公共檔，HK + US 合併去重，給其它 server 用
#   - combined_stocks_hk.json   ：HK 內部 ROUND0 cache
#   - combined_stocks_us.json   ：US 內部 ROUND0 cache
#   - 除非 --reset / --reset-all / 刪除對應 cache 檔，否則不重做 ROUND0
# ══════════════════════════════════════════
ROUND0_CACHE_HK_FILE   = os.path.join(PUBLIC_DIR, "combined_stocks_hk.json")
ROUND0_CACHE_US_FILE   = os.path.join(PUBLIC_DIR, "combined_stocks_us.json")
ROUND0_CACHE_META_FILE = os.path.join(PUBLIC_DIR, "combined_stocks_cache_meta.json")

def get_round0_cache_file(market=None):
    m = (market or MARKET_MODE or "").upper()
    if m == "HK":
        return ROUND0_CACHE_HK_FILE
    if m == "US":
        return ROUND0_CACHE_US_FILE
    return os.path.join(PUBLIC_DIR, f"combined_stocks_{m.lower()}.json")

def _round0_sort_tabs(tabs):
    def _key(x):
        x = str(x).strip()
        m = re.match(r'^([A-Za-z]+)(\d+)$', x)
        if m:
            return (m.group(1).lower(), int(m.group(2)))
        return (x.lower(), 0)
    return sorted({str(t).strip() for t in tabs if str(t).strip()}, key=_key)

def _atomic_write_json(filepath, data):
    tmp = f"{filepath}.tmp.{os.getpid()}"
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, filepath)

def refresh_round0_public_file():
    merged_tabs = set()
    merged_rows = {}
    source_files = []

    for mkt in ("HK", "US"):
        cache_file = get_round0_cache_file(mkt)
        if not os.path.exists(cache_file):
            continue

        try:
            with open(cache_file, 'r', encoding='utf-8') as f:
                cache = json.load(f)

            if cache.get("market") != mkt:
                continue

            source_files.append(os.path.basename(cache_file))
            merged_tabs.update(cache.get("tabs", []))

            for row in cache.get("data", []):
                if not row or len(row) < 2:
                    continue
                code = str(row[0]).strip()
                if not code:
                    continue

                fixed = list(row)
                while len(fixed) < 4:
                    fixed.append("")

                merged_rows[code] = fixed

        except Exception as e:
            try:
                lp(f"   ⚠️ 合併 {cache_file} 失敗: {e}")
            except:
                pass

    public_data = {
        "market": "BOTH",
        "tabs": _round0_sort_tabs(merged_tabs),
        "data": list(merged_rows.values())
    }

    _atomic_write_json(ROUND0_FILE, public_data)

    meta = {
        "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
        "public_file": os.path.basename(ROUND0_FILE),
        "cache_files": source_files,
        "count": len(public_data["data"])
    }
    _atomic_write_json(ROUND0_CACHE_META_FILE, meta)

    return public_data

def ensure_round0_cache_from_legacy(market):
    cache_file = get_round0_cache_file(market)
    if os.path.exists(cache_file):
        return cache_file

    if os.path.exists(ROUND0_CACHE_META_FILE):
        return cache_file

    if not os.path.exists(ROUND0_FILE):
        return cache_file

    try:
        with open(ROUND0_FILE, 'r', encoding='utf-8') as f:
            legacy = json.load(f)

        if legacy.get("market") == market and legacy.get("tabs") and legacy.get("data"):
            _atomic_write_json(cache_file, legacy)
            try:
                lp(f"   ♻️ 已將舊版 combined_stocks.json 遷移為 {os.path.basename(cache_file)}")
            except:
                pass

    except:
        pass

    return cache_file


# ══════════════════════════════════════════
# 市場控制與 UI 常數
# ══════════════════════════════════════════
FORCE_MARKET    = "AUTO"
KLINE_BAR_COUNT = 5
MAX_DEPTH       = 29
MARKET_MODE     = "AUTO"
DOCKER_SHM      = ""

RID_TAB_TITLE      = "cn.futu.trader:id/tab_title"
RID_OPTIONAL_GROUP = "cn.futu.trader:id/filter_entrance_optional_group"
RID_FILTER_TITLE   = "cn.futu.trader:id/filter_entrance_title"
RID_HDR_LIST       = "cn.futu.trader:id/header_to_stock_list"
RID_INDEX          = "cn.futu.trader:id/index_info_content"
RID_STOCK_NAME     = "cn.futu.trader:id/tv_stock_name_and_code"
RID_RV_ROOT        = "cn.futu.trader:id/rv_root"

APP_PACKAGE  = "cn.futu.trader"
APP_ACTIVITY = "cn.futu.trader/.launch.activity.LaunchActivity"

TAB_VALID_PAT = re.compile(r'^(us|hk)\d+$', re.IGNORECASE)

# ══════════════════════════════════════════
# 日誌追加器 (JSON Lines 格式)
# ══════════════════════════════════════════
def append_log(event: str, details: str = ""):
    try:
        log_entry = {
            "time": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            "market": MARKET_MODE,
            "event": event,
            "details": details
        }
        with open(LOG_FILE, 'a', encoding='utf-8') as f:
            f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
    except:
        pass

# ══════════════════════════════════════════
# 命令行參數解析
# ══════════════════════════════════════════
def parse_args():
    parser = argparse.ArgumentParser(description='Kline Probe - 富途 K線採集工具')
    parser.add_argument('market', nargs='?', choices=['HK', 'US', 'AUTO'], default='AUTO')
    parser.add_argument('--reset', action='store_true', help='刪除當前市場的緩存')
    parser.add_argument('--bars', type=int, choices=range(1, 6), metavar='N', help='K線段數')
    parser.add_argument('--reset-all', action='store_true', help='刪除所有市場緩存')
    return parser.parse_args()

# ══════════════════════════════════════════
# 工具函數
# ══════════════════════════════════════════
def get_market_mode():
    if FORCE_MARKET in ("HK", "US"):
        return FORCE_MARKET

    ny_time = datetime.now(zoneinfo.ZoneInfo("America/New_York"))
    ny_min  = ny_time.hour * 60 + ny_time.minute
    ny_wday = ny_time.weekday()

    if ny_wday == 4 and ny_min >= 1200: return "IDLE"
    if ny_wday == 5: return "IDLE"
    if ny_wday == 6 and ny_min < 1080: return "IDLE"

    if 0 <= ny_wday <= 4 and 241 <= ny_min < 1209:
        return "US"

    return "HK"

def ts():
    return datetime.now().strftime('%H:%M:%S')

def lp(msg):
    print(f"[{ts()}] {msg}", flush=True)

def cn(v, is_t=False):
    if not v: return ""
    val_str = v.replace(",", "").strip()
    multiplier = 1.0

    if "兆" in val_str:
        multiplier = 1000000000000.0
        val_str = val_str.replace("兆", "")
    elif "億" in val_str:
        multiplier = 100000000.0
        val_str = val_str.replace("億", "")
    elif "千萬" in val_str:
        multiplier = 10000000.0
        val_str = val_str.replace("千萬", "")
    elif "百萬" in val_str:
        multiplier = 1000000.0
        val_str = val_str.replace("百萬", "")
    elif "萬" in val_str:
        multiplier = 10000.0
        val_str = val_str.replace("萬", "")
    elif "千" in val_str:
        multiplier = 1000.0
        val_str = val_str.replace("千", "")
    elif "百" in val_str:
        multiplier = 100.0
        val_str = val_str.replace("百", "")

    try:
        num = float(val_str) * multiplier
        if is_t:
            num = round(num, 4)
            if num.is_integer():
                return str(int(num))
            else:
                return f"{num:.4f}".rstrip('0').rstrip('.')
        else:
            num = round(num, 3)
            if num.is_integer():
                return str(int(num))
            else:
                return f"{num:.3f}".rstrip('0').rstrip('.')
    except ValueError:
        return val_str

def is_valid_tab(text):
    return bool(TAB_VALID_PAT.match(text.strip()))

# ══════════════════════════════════════════
# 格式解析工具
# ══════════════════════════════════════════
def extract_code_and_name(raw_name: str):
    clean = re.sub(r'\s*[(（](?:期貨)?主連 [^)）]*[)）]', '', raw_name).strip()
    parts = clean.split(None, 1)
    code      = parts[0] if parts else clean
    name_only = parts[1].strip() if len(parts) > 1 else ""
    name_only = re.sub(r'\s*期貨主連\s*$', '', name_only).strip()
    name_only = re.sub(r'\s*主連\s*$',    '', name_only).strip()
    name_only = re.sub(r'main\s*$',       '', name_only, flags=re.IGNORECASE).strip()
    return code, name_only

def clean_stock_name(name: str) -> str:
    name = re.sub(r'\s*[(（]\d{3,5}[)）]\s*', ' ', name).strip()
    name = re.sub(r'\s*期貨主連\s*$', '', name).strip()
    name = re.sub(r'\s*主連\s*$',    '', name).strip()
    name = re.sub(r'\s*main\s*$',    '', name, flags=re.IGNORECASE).strip()
    return name

def parse_legend_new(legend: str) -> dict:
    if not legend or "G1" not in legend:
        return None

    bars = []
    i = 1
    while True:
        t_m = re.search(rf'T{i}:([-\d,\.]+[百千萬億兆]*)', legend)
        if not t_m: break
        o_m = re.search(rf'O{i}:([-\d,\.]+[百千萬億兆]*)', legend)
        c_m = re.search(rf'C{i}:([-\d,\.]+[百千萬億兆]*)', legend)
        h_m = re.search(rf'H{i}:([-\d,\.]+[百千萬億兆]*)', legend)
        l_m = re.search(rf'L{i}:([-\d,\.]+[百千萬億兆]*)', legend)
        v_m = re.search(rf'V{i}:([-\d,\.]+[百千萬億兆]*)', legend)
        a_m = re.search(rf'A{i}:([-\d,\.]+[百千萬億兆]*)', legend)
        bars.append({
            "T": cn(t_m.group(1), is_t=True),
            "O": cn(o_m.group(1)) if o_m else "",
            "C": cn(c_m.group(1)) if c_m else "",
            "H": cn(h_m.group(1)) if h_m else "",
            "L": cn(l_m.group(1)) if l_m else "",
            "V": cn(v_m.group(1)) if v_m else "",
            "A": cn(a_m.group(1)) if a_m else "",
        })
        i += 1

    return {"bars": bars}

# ══════════════════════════════════════════
# 基礎設施層
# ══════════════════════════════════════════
def is_docker_running():
    try:
        result = subprocess.run(
            f"docker inspect -f '{{{{.State.Running}}}}' {DOCKER_NAME} 2>/dev/null",
            shell=True, capture_output=True, text=True, timeout=5)
        return result.stdout.strip() == "true"
    except: return False

def is_device_responding():
    try:
        result = subprocess.run(
            f"adb -s {DEVICE_ID} shell getprop ro.build.version.release",
            shell=True, capture_output=True, text=True, timeout=3)
        return result.returncode == 0 and bool(result.stdout.strip())
    except: return False

def docker_recovery():
    global MARKET_MODE
    target_data_dir = f"/root/redroid_1{MARKET_MODE.lower()}"

    lp(f"\n🚨 [環境重建] 重建容器 {DOCKER_NAME} (來源種子: {target_data_dir})...")
    append_log("DOCKER_REBUILD", f"Target seed: {target_data_dir}")
    RAM_DIR = f"/dev/shm/{DOCKER_NAME}_ram"

    subprocess.run(f"docker rm -f {DOCKER_NAME} 2>/dev/null", shell=True, timeout=20)
    subprocess.run(f"fuser -k {DOCKER_PORT}/tcp 2>/dev/null", shell=True, timeout=5)
    subprocess.run("pkill -f 'adb' 2>/dev/null", shell=True, timeout=5)

    os.makedirs(DOCKER_SHM, exist_ok=True)
    if os.path.isdir(RAM_DIR):
        subprocess.run(f"rm -rf {RAM_DIR}", shell=True, timeout=20)
    os.makedirs(RAM_DIR, exist_ok=True)
    os.chmod(RAM_DIR, 0o777)

    if os.path.isdir(target_data_dir):
        lp(f"📁 複製數據中 ({target_data_dir} -> {RAM_DIR})...")
        subprocess.run(
            f"cp --reflink=auto -a {target_data_dir}/. {RAM_DIR}/ 2>/dev/null || "
            f"cp -a {target_data_dir}/. {RAM_DIR}/ 2>/dev/null || true",
            shell=True, timeout=60)
    else:
        lp(f"⚠️ 找不到目錄 {target_data_dir}，建立空目錄！")
        os.makedirs(target_data_dir, exist_ok=True)

    lp("🐳 啟動 Docker 容器 (90x790, 30fps, 72dpi, 綁定Core0, 1.0核)...")
    docker_run_cmd = (
        f"docker run -itd --privileged "
        f"--name {DOCKER_NAME} "
        f"-p {DOCKER_PORT}:5555 "
        f"--dns 1.1.1.1 --dns 8.8.8.8 "
        f"--shm-size=2g --memory=4.5g --memory-swap=4.5g --cpuset-cpus=0 --cpus=0.9 "
        f"--log-opt max-size=50m --log-opt max-file=3 "
        f"-v {DOCKER_SHM}:/data/local/tmp "
        f"-v {RAM_DIR}:/data "
        f"redroid/redroid:12.0.0_64only-latest "
        f"androidboot.redroid_width=90 androidboot.redroid_height=790 "
        f"androidboot.redroid_dpi=72 "
        f"androidboot.redroid_fps=15 androidboot.redroid_gpu_mode=guest "
        f"androidboot.use_memfd=true "
        f"persist.sys.timezone=Asia/Hong_Kong persist.sys.locale=zh-HK "
        f"debug.sf.nobootanimation=1 "
        f"ro.sys.fw.bg_apps_limit=10 dalvik.vm.heapstartsize=256m "
        f"dalvik.vm.heapgrowthlimit=2048m "
        f"dalvik.vm.heapsize=4096m dalvik.vm.dex2oat-filter=speed "
        f"dalvik.vm.image-dex2oat-filter=speed "
        f"dalvik.vm.dex2oat-threads=1"
    )

    result = subprocess.run(docker_run_cmd, shell=True, capture_output=True, text=True, timeout=15)
    if "Error" in result.stderr or result.returncode != 0:
        lp(f"⚠️ Docker 啟動異常: {result.stderr.strip()[:100]}")
        time.sleep(2)
        subprocess.run(f"docker rm -f {DOCKER_NAME} 2>/dev/null", shell=True, timeout=15)
        subprocess.run(docker_run_cmd, shell=True, timeout=15)

    subprocess.run("adb start-server > /dev/null 2>&1", shell=True, timeout=5)

    def connect_adb_bg():
        for _ in range(15):
            r = subprocess.run(f"adb connect {DEVICE_ID}", shell=True, capture_output=True, text=True, timeout=5)
            if "connected" in r.stdout or "already connected" in r.stdout: break
            time.sleep(1)

    threading.Thread(target=connect_adb_bg, daemon=True).start()
    lp("✅ Docker & ADB 啟動中\n")
    return True

def wait_device_ready(timeout=60, check_interval=2):
    lp(f"⏳ 檢測設備就緒... (最多 {timeout}s)")
    start_time      = time.time()
    check_count     = 0
    last_check_time = 0

    while time.time() - start_time < timeout:
        current_time = time.time()
        if current_time - last_check_time < check_interval:
            time.sleep(0.1)
            continue

        last_check_time = current_time
        check_count    += 1
        elapsed         = int(current_time - start_time)

        if not is_docker_running() or not is_device_responding():
            if check_count % 3 == 1: lp(f"   [{elapsed}s] ⏳ Docker/設備未就緒...")
            time.sleep(0.5)
            continue

        try:
            boot_result = subprocess.run(
                f"adb -s {DEVICE_ID} shell getprop sys.boot_completed", shell=True, capture_output=True, text=True, timeout=3)
            if "1" in boot_result.stdout:
                lp(f"✅ 設備已就緒！({elapsed}s)")
                subprocess.run(f'adb -s {DEVICE_ID} shell "stop logd;"', shell=True, capture_output=True, timeout=5)
                return True
        except: pass

    lp(f"❌ 設備超時未就緒 ({timeout}s)")
    return False

# ══════════════════════════════════════════
# 應用層工具
# ══════════════════════════════════════════
def find_pid():
    try:
        r = subprocess.run(f"adb -s {DEVICE_ID} shell pidof {APP_PACKAGE}", shell=True, capture_output=True, text=True, timeout=3)
        pid_str = r.stdout.strip()
        if pid_str:
            parts = pid_str.split()
            if parts: return int(parts[0])
    except: pass
    return None

def _dismiss_popups(d, timeout=5.0):
    lp("   → 快速檢查並關閉彈窗...")
    t_start = time.time()
    while time.time() - t_start < timeout:
        try:
            xml = d.dump_hierarchy(compressed=False)
            if not xml: break

            popped = False

            for text_val in ["以後再說", "我知道了", "跳過", "暫不", "關閉", "不要了"]:
                if text_val in xml:
                    node = d(text=text_val)
                    if node.exists(timeout=0.5):
                        node.click()
                        lp(f"      [自動清理] 已點擊文字: {text_val}")
                        popped = True
                        break

            if popped:
                time.sleep(0.5)
                continue

            target_ids = [
                "cn.futu.trader:id/popup_ad_close",
                "cn.futu.trader:id/iv_close",
                "cn.futu.trader:id/btn_close",
                "cn.futu.trader:id/close_btn",
                "cn.futu.trader:id/tv_close",
                "cn.futu.trader:id/close_image",
                "cn.futu.trader:id/btn_cancel",
                "cn.futu.trader:id/iv_cancel",
                "cn.futu.trader:id/cancel"
            ]
            for cid in target_ids:
                if cid in xml:
                    node = d(resourceId=cid)
                    if node.exists(timeout=0.5):
                        node.click()
                        lp(f"      [自動清理] 已點擊 ID: {cid}")
                        popped = True
                        break

            if popped:
                time.sleep(0.5)
                continue

            for desc in ["關閉", "close", "dismiss", "cancel"]:
                node = d(description=desc)
                if node.exists(timeout=0.5):
                    node.click()
                    lp(f"      [自動清理] 已點擊描述: {desc}")
                    popped = True
                    break

            if popped:
                time.sleep(0.5)
                continue
            else:
                break
        except Exception as e:
            lp(f"      [自動清理異常]: {e}")
            break

def ensure_app_running(d, force_restart=False):
    if force_restart:
        lp(f"   🔄 強制關閉 App...")
        subprocess.run(f"adb -s {DEVICE_ID} shell am force-stop --user 0 {APP_PACKAGE}", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        time.sleep(1.0)

    pid = find_pid()
    start_cmd = (f"adb -s {DEVICE_ID} shell am start --user 0 -n {APP_ACTIVITY}")

    if pid and not force_restart:
        lp(f"✅ 富途 App 運行中 (PID: {pid})")
        subprocess.run(start_cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        d.app_wait(APP_PACKAGE, front=True, timeout=3.0)
    else:
        lp(f"⚡ 啟動富途 App ({MARKET_MODE})...")
        subprocess.run(start_cmd, shell=True)
        app_ready = d(resourceId=RID_TAB_TITLE).wait(timeout=8.0)
        opt_ready = d(resourceId=RID_OPTIONAL_GROUP).wait(timeout=3.0)
        if not app_ready and not opt_ready:
            _dismiss_popups(d, timeout=3.0)

def restart_app_and_enter_list(d):
    lp("   🚨 [極速修復] 強制重啟富途並進入 K 線頁...")
    append_log("APP_RESTART", "Triggered app restart")

    subprocess.run(f"adb -s {DEVICE_ID} shell am force-stop --user 0 {APP_PACKAGE}", shell=True)
    time.sleep(1.0)
    subprocess.run(f"adb -s {DEVICE_ID} shell am start --user 0 -n {APP_ACTIVITY}", shell=True)

    tab = d(resourceId=RID_TAB_TITLE, text="自選")
    if not tab.wait(timeout=10.0):
        _dismiss_popups(d, timeout=3.0)
        tab.wait(timeout=2.0)

    for _ in range(3):
        try:
            if tab.exists:
                tab.click()
                btn = d(resourceId=RID_HDR_LIST)
                if btn.wait(timeout=3.0):
                    btn.click()
                    opt = d(resourceId=RID_OPTIONAL_GROUP)
                    if opt.wait(timeout=3.0): break
        except:
            d.press("back")
            time.sleep(0.5)
    lp("   ✅ 極速重啟完畢 (已進入 K 線頁)")

# ══════════════════════════════════════════
# UIA 健康監測與三級修復（v8.10 國家級精準版）
# 基線實測：window_size avg=0.273s max=0.768s / uia restart=1.51s
# ══════════════════════════════════════════
_switch_latency_history = []
_SWITCH_HISTORY_SIZE    = 20
_SWITCH_DEGRADED_RATIO  = 1.8
_SWITCH_MIN_SAMPLES     = 5
_JSONRPC_HEALTH_THRESHOLD = 1.0   # 實測 max=0.768s，取 1.0s 零誤報

def record_switch_latency(sw_time: float):
    _switch_latency_history.append(sw_time)
    if len(_switch_latency_history) > _SWITCH_HISTORY_SIZE:
        _switch_latency_history.pop(0)

def is_switch_degraded(sw_time: float):
    """回傳 (是否劣化, 本次耗時, 歷史均值)；樣本不足永遠 False。"""
    if len(_switch_latency_history) < _SWITCH_MIN_SAMPLES:
        return False, sw_time, 0.0
    avg = sum(_switch_latency_history) / len(_switch_latency_history)
    return sw_time > avg * _SWITCH_DEGRADED_RATIO, sw_time, avg

def check_jsonrpc_latency(d, label=""):
    """量測 jsonrpc latency（window_size）；失敗回傳 -1.0。"""
    try:
        t0 = time.perf_counter()
        _ = d.window_size()
        return time.perf_counter() - t0
    except Exception as e:
        lp(f"      [jsonrpc] {label} 異常: {e}")
        return -1.0

def _is_jsonrpc_healthy(d) -> bool:
    lat = check_jsonrpc_latency(d, "healthcheck")
    if lat >= 0:
        lp(f"      [jsonrpc] healthcheck = {lat*1000:.0f}ms (閾值 {_JSONRPC_HEALTH_THRESHOLD*1000:.0f}ms)")
    return 0 <= lat < _JSONRPC_HEALTH_THRESHOLD

def three_level_recovery(d, d_ref, reason=""):
    """
    L1: restart uiautomator（實測 1.51s，App 不動、畫面不動）
    L2: force-stop App + relaunch
    L3: docker_recovery + u2 重連
    """
    lp(f"\n🔧 [三級修復] 啟動，原因: {reason}")
    append_log("THREE_LEVEL_RECOVERY_START", reason)

    # ── L1：只重啟 uiautomator ─────────────────────────────
    lp("   🔧 [L1] 重啟 uiautomator (App 保持運行)...")
    l1_ok = False
    try:
        def _l1_restart():
            try:
                d.uiautomator.stop()
                time.sleep(0.5)
                d.uiautomator.start()
            except Exception as _e:
                lp(f"      [L1] stop/start 異常: {_e}")
        t = threading.Thread(target=_l1_restart, daemon=True)
        t.start()
        t.join(timeout=15)

        if t.is_alive():
            lp("   ❌ [L1] stop/start 超過 15s 未返回，視為失敗")
        else:
            time.sleep(1.0)
            if _is_jsonrpc_healthy(d):
                l1_ok = True
                lp("   ✅ [L1] uiautomator 重啟成功，jsonrpc 恢復正常")
                append_log("THREE_LEVEL_RECOVERY", "L1 OK")
    except Exception as e:
        lp(f"   ❌ [L1] 失敗: {e}")

    if l1_ok:
        _switch_latency_history.clear()
        return d

    # ── L2：force-stop App + relaunch ─────────────────────
    lp("   🔧 [L2] 強制重啟 App...")
    l2_ok = False
    try:
        restart_app_and_enter_list(d)
        if _is_jsonrpc_healthy(d):
            l2_ok = True
            lp("   ✅ [L2] App 重啟成功，jsonrpc 正常")
            append_log("THREE_LEVEL_RECOVERY", "L2 OK")
    except Exception as e:
        lp(f"   ❌ [L2] 失敗: {e}")

    if l2_ok:
        _switch_latency_history.clear()
        return d

    # ── L3：docker_recovery + 重連 ─────────────────────────
    lp("   🔧 [L3] Docker 重建...")
    append_log("THREE_LEVEL_RECOVERY", "L3 docker_recovery")
    try:
        docker_recovery()
        if wait_device_ready(timeout=120, check_interval=2):
            new_d = u2.connect(DEVICE_ID)
            new_d.implicitly_wait(10.0)
            try:
                new_d.jsonrpc.setConfigurator({"waitForIdleTimeout": 0, "waitForSelectorTimeout": 0})
            except:
                pass
            d_ref[0] = new_d
            lp("   ✅ [L3] Docker 重建並重連成功")
            append_log("THREE_LEVEL_RECOVERY", "L3 OK")
            _switch_latency_history.clear()
            try:
                restart_app_and_enter_list(new_d)
            except:
                pass
            return new_d
        lp("   ❌ [L3] 設備超時未就緒")
    except Exception as e:
        lp(f"   ❌ [L3] 異常: {e}")
    return d_ref[0]

def switch_tab(d, tab_name):
    t0 = time.perf_counter()

    def _one_attempt(attempt_no, total):
        try:
            opt = d(resourceId=RID_OPTIONAL_GROUP)
            if not opt.exists(timeout=2.0):
                d.press("back")
                btn = d(resourceId=RID_HDR_LIST)
                if btn.wait(timeout=1.5): btn.click()
                if not opt.wait(timeout=2.0): return False

            opt.click()
            target = d(text=tab_name)
            if not target.wait(timeout=2.0):
                try: opt.click()
                except: pass
                return False

            target.click()
            title_node = d(resourceId=RID_FILTER_TITLE, text=tab_name)
            if title_node.wait(timeout=2.0): return True
            return True
        except: return False

    if not find_pid():
        lp(f"   🚨 [快速偵測] App 已崩潰 (PID 不存在)，立即 kill 重啟並繼續本 tab...")
        restart_app_and_enter_list(d)
        for i in range(1, 6):
            if _one_attempt(i, 5): return time.perf_counter() - t0
            time.sleep(0.5)

    for i in range(1, 4):
        if _one_attempt(i, 3): return time.perf_counter() - t0
        if not find_pid():
            lp(f"   🚨 [快速偵測] 嘗試中發現 App 已崩潰，立即 kill 重啟並繼續本 tab...")
            restart_app_and_enter_list(d)
            for j in range(1, 6):
                if _one_attempt(j, 5): return time.perf_counter() - t0
                time.sleep(0.5)

    lp(f"   ❌ 3 次均失敗，開始診斷...")
    if not find_pid():
        lp(f"   🚨 PID 不存在，App 崩潰，執行重啟...")
        restart_app_and_enter_list(d)
    else:
        for _ in range(3):
            d.press("back")
            time.sleep(1.0)
            if d(resourceId=RID_OPTIONAL_GROUP).exists(timeout=1.5): break
        if not d(resourceId=RID_OPTIONAL_GROUP).exists(timeout=1.0):
            btn = d(resourceId=RID_HDR_LIST)
            if btn.exists(timeout=2.0):
                btn.click()
                time.sleep(2.0)

    for i in range(1, 6):
        if _one_attempt(i, 5): return time.perf_counter() - t0
        time.sleep(0.8)

    restart_app_and_enter_list(d)
    for i in range(1, 6):
        if _one_attempt(i, 5): return time.perf_counter() - t0
        time.sleep(0.8)

    raise RuntimeError(f"switch_tab({tab_name}) 徹底失敗")

def do_dump(d, label=""):
    for attempt in range(1, 5):
        try:
            xml = d.dump_hierarchy(compressed=False)
            ln = len(xml) if xml else 0
            lp(f"      dump[{label}] attempt={attempt} len={ln}")

            if xml and ln > 200:
                ad_keywords = ["popup_ad_close", "以後再說", "iv_close", "我知道了", "跳過", "close_btn"]
                if any(kw in xml for kw in ad_keywords):
                    lp(f"      ⚠️ 在 dump 中發現疑似廣告彈窗，觸發清理...")
                    _dismiss_popups(d, timeout=3.0)
                    time.sleep(0.5)
                    continue
                return xml
        except Exception as e:
            lp(f"      dump[{label}] attempt={attempt} ERR: {type(e).__name__}: {e}")
        time.sleep(1.0)
    return ""

def get_all_tab_names(d):
    try:
        tabs = []
        tab_objs = d(resourceId=RID_TAB_TITLE)
        for i in range(tab_objs.count):
            try:
                text = tab_objs[i].get_text()
                if text and text.strip(): tabs.append(text.strip())
            except: pass
        return tabs
    except: return []

def swipe_up(d):
    try:
        w, h = d.window_size()
        d.swipe(w // 2, h * 3 // 4, w // 2, h // 4, duration=0.3)
    except: pass

def webhook_notify(filepath):
    try: requests.post(WEBHOOK_URL, json={"filepath": filepath}, timeout=3)
    except: pass

def webhook_notify_mapping(filepath):
    try:
        with open(filepath, 'r', encoding='utf-8') as f: data = json.load(f)
        requests.post("http://213.35.112.20:8080/api/update-mapping", json=data, timeout=5)
    except: pass

# ══════════════════════════════════════════
# ROUND 0：爬取 name-code-F 對照表
# ══════════════════════════════════════════
def get_mid_y(bounds_str):
    try:
        m = re.match(r'\[-?\d+,(-?\d+)\]\[-?\d+,(-?\d+)\]', bounds_str)
        if m: return (int(m.group(1)) + int(m.group(2))) / 2
    except: pass
    return -1

def parse_list_page_from_xml(xml):
    stocks = {}
    try:
        root = ET.fromstring(xml)
        code_nodes = root.findall(".//node[@resource-id='cn.futu.trader:id/stockCodeText']")
        name_nodes = root.findall(".//node[@resource-id='cn.futu.trader:id/stockNameText']")

        f_nodes = []
        for node in root.iter('node'):
            text = node.attrib.get("text", "").strip()
            if re.search(r'[百千萬億兆]$', text) and node.attrib.get("class") == "android.widget.TextView":
                f_nodes.append(node)

        for code_node in code_nodes:
            code = code_node.attrib.get("text", "").strip()
            if not code: continue

            c_bounds = code_node.attrib.get("bounds", "")
            c_mid_y = get_mid_y(c_bounds)
            if c_mid_y < 0: continue

            name = ""
            for name_node in name_nodes:
                if abs(get_mid_y(name_node.attrib.get("bounds", "")) - c_mid_y) < 35:
                    name = clean_stock_name(name_node.attrib.get("text", "").strip())
                    break

            f_val = ""
            for f_node in f_nodes:
                if abs(get_mid_y(f_node.attrib.get("bounds", "")) - c_mid_y) < 35:
                    f_val = cn(f_node.attrib.get("text", "").strip())
                    break

            stocks[code] = {"name": name if name else code, "F": f_val}
    except: pass
    return stocks

def switch_to_tab_simple(d, tab_name, max_attempts=3):
    lp(f"   🔀 切換到 {tab_name}...")
    for attempt in range(max_attempts):
        try:
            tab_obj = d(resourceId=RID_TAB_TITLE, text=tab_name)
            if tab_obj.exists(timeout=3.0):
                tab_obj.click()
                time.sleep(1.5)
                lp(f"   ✅ 已點擊 {tab_name}")
                return True
        except: time.sleep(1)
    return False

def round0_collect_name_code_mapping(d):
    lp("=" * 60)
    lp("🔍 ROUND 0：爬取 name-code-F 對照表")
    lp("=" * 60)
    append_log("R0_START", "Starting ROUND 0")

    tabs = []
    for attempt in range(3):
        raw_tabs = get_all_tab_names(d)
        tabs = [t.strip() for t in raw_tabs if is_valid_tab(t.strip())]
        tabs.sort(key=lambda x: (x[:2].lower(), int(x[2:])))
        if tabs: break
        d.press("back"); time.sleep(2)

    if not tabs:
        lp("   ❌ 找不到有效 tab，啟動修復...")
        restart_app_and_enter_list(d)
        for _ in range(2): d.press("back"); time.sleep(1)
        raw_tabs = get_all_tab_names(d)
        tabs = [t.strip() for t in raw_tabs if is_valid_tab(t.strip())]
        tabs.sort(key=lambda x: (x[:2].lower(), int(x[2:])))
        if not tabs: return [], {}, {}

    all_stocks, done_tabs, pending_tabs = {}, set(), list(tabs)
    default_e = "390" if MARKET_MODE == "US" else "330"

    while pending_tabs:
        tab_name = pending_tabs.pop(0)
        if tab_name in done_tabs: continue
        lp(f"\n📂 Tab: {tab_name}  (剩餘: {pending_tabs})")

        if not switch_to_tab_simple(d, tab_name):
            done_tabs.add(tab_name)
            continue

        done_tabs.add(tab_name)
        time.sleep(0.5)

        try:
            current_tabs = [t.strip() for t in get_all_tab_names(d) if is_valid_tab(t.strip())]
            for new_t in current_tabs:
                if new_t not in done_tabs and new_t not in pending_tabs:
                    pending_tabs.append(new_t)
        except: pass

        tab_stocks, seen_fp, no_new_count = {}, set(), 0

        for page in range(120):
            xml = do_dump(d, label=f"{tab_name}_p{page+1}")
            if not xml: break

            page_stocks = parse_list_page_from_xml(xml)
            page_new = 0

            for code, info in page_stocks.items():
                if code not in tab_stocks:
                    tab_stocks[code] = info["name"]
                    page_new += 1
                if code not in all_stocks:
                    all_stocks[code] = {"name": info["name"], "F": info["F"], "E": default_e}

            codes = list(page_stocks.keys())
            fp = "|".join(codes[:3] + ["---"] + codes[-3:]) if codes else ""
            lp(f"      page {page+1}: new={page_new} total={len(tab_stocks)}")

            if fp and fp in seen_fp and page_new == 0:
                no_new_count += 1
                if no_new_count >= 3: break
            else: no_new_count = 0
            if fp: seen_fp.add(fp)

            swipe_up(d)
            time.sleep(0.35)

        lp(f"   📦 {tab_name}: {len(tab_stocks)} 支 (累計 {len(all_stocks)})")
        try:
            current_tabs = [t.strip() for t in get_all_tab_names(d) if is_valid_tab(t.strip())]
            for new_t in current_tabs:
                if new_t not in done_tabs and new_t not in pending_tabs:
                    pending_tabs.append(new_t)
        except: pass

    lp(f"\n📊 所有 tab 完畢（共 {len(done_tabs)} 個）")

    sorted_tabs = sorted(done_tabs, key=lambda x: (x[:2].lower(), int(x[2:])))

    OW_REMOTE_URL = "http://213.35.112.20:8080/ow.json"
    lp(f"   📂 嘗試從主腦下載中央設定 {OW_REMOTE_URL} ...")

    try:
        resp = requests.get(OW_REMOTE_URL, timeout=5)
        if resp.status_code == 200:
            ow_data = resp.json()
            overwrite_count = 0
            for code, info in all_stocks.items():
                if code in ow_data:
                    if "F" in ow_data[code]: info["F"] = str(ow_data[code]["F"])
                    if "E" in ow_data[code]: info["E"] = str(ow_data[code]["E"])
                    overwrite_count += 1
            lp(f"   ✅ 已套用主腦 {overwrite_count} 筆自訂設定。")
        else:
            lp(f"   ⚠️ 無法讀取主腦 ow.json (HTTP Code: {resp.status_code})")
    except Exception as e:
        lp(f"   ⚠️ 讀取遠端 ow.json 失敗: {e}")

    data_list = [[code, info["name"], info["F"], info["E"]] for code, info in all_stocks.items()]
    market_cache_data = {"market": MARKET_MODE, "tabs": sorted_tabs, "data": data_list}

    cache_file = get_round0_cache_file(MARKET_MODE)
    _atomic_write_json(cache_file, market_cache_data)

    lp(f"💾 ROUND 0 市場快取已存: {cache_file} ({len(all_stocks)} 股票)")

    public_data = refresh_round0_public_file()
    lp(f"📦 對外 combined_stocks.json 已合併去重: {ROUND0_FILE} ({len(public_data.get('data', []))} 股票)")

    webhook_notify_mapping(ROUND0_FILE)
    append_log("R0_END", f"Collected {len(all_stocks)} stocks; cache={os.path.basename(cache_file)}")

    name_to_symbol = {}
    symbol_order = {}
    idx = 0
    for code, info in all_stocks.items():
        n = info["name"]
        cln  = re.sub(r'\s*[(（](?:期貨)?主連 [^)）]*[^)）]*[)）]', '', n).strip()
        name_to_symbol[cln] = code
        name_to_symbol[n]   = code
        symbol_order[code]  = idx
        idx += 1

    return sorted_tabs, name_to_symbol, symbol_order

# ══════════════════════════════════════════
# ROUND 2：XML dump 無限迴圈
# ══════════════════════════════════════════
def parse_klines_from_xml_new(xml, name_to_symbol):
    results, seen = [], set()
    try:
        root = ET.fromstring(xml)
        for item in root.findall(f".//node[@resource-id='{RID_RV_ROOT}']"):
            name_el = item.find(f".//node[@resource-id='{RID_STOCK_NAME}']")
            name_raw = name_el.attrib.get("text", "").strip() if name_el is not None else ""
            if not name_raw: continue

            legend_el = item.find(f".//node[@resource-id='{RID_INDEX}']")
            if legend_el is None: continue
            legend = legend_el.attrib.get("text", "")
            if not legend or "G1" not in legend: continue

            parsed = parse_legend_new(legend)
            if not parsed: continue

            code, _ = extract_code_and_name(name_raw)
            clean_full = re.sub(r'\s*[(（](?:期貨)?主連 [^)）]*[)）]', '', name_raw).strip()

            cleaned_name = clean_stock_name(name_raw)

            symbol = (
                name_to_symbol.get(clean_full) or
                name_to_symbol.get(cleaned_name) or
                name_to_symbol.get(name_raw) or
                name_to_symbol.get(code) or
                code
            )
            if not symbol: continue

            bars = parsed.get("bars", [])
            bars.reverse()

            for bar in bars:
                T = bar.get("T", "")
                if (symbol, T) in seen: continue
                seen.add((symbol, T))
                results.append({
                    "symbol": symbol,
                    "data": {"T": T, "O": bar.get("O", ""), "C": bar.get("C", ""), "H": bar.get("H", ""), "L": bar.get("L", ""), "V": bar.get("V", ""), "A": bar.get("A", "")}
                })
    except: pass
    return results


# ══════════════════════════════════════════════════════════════════
# Watchdog 感知包裝：switch_tab / do_dump 的帶狗版本
# ══════════════════════════════════════════════════════════════════
class WatchdogTriggered(Exception):
    """Watchdog 已觸發 Docker 重建，主迴圈應捕捉此異常並重連。"""
    pass

def _check_watchdog_triggered():
    """在任意點位呼叫；若 watchdog 已觸發則拋出異常打斷阻塞操作。"""
    if _watchdog_triggered.is_set():
        raise WatchdogTriggered("Watchdog 已觸發，Docker 重建中/已完成")

def switch_tab_watched(d, tab_name):
    """帶 25s watchdog 的 switch_tab 包裝。"""
    watchdog_reset(f"switch_tab:{tab_name}")
    try:
        _check_watchdog_triggered()
        result = switch_tab(d, tab_name)
        _check_watchdog_triggered()
        return result
    finally:
        watchdog_stop()

def do_dump_watched(d, label=""):
    """帶 25s watchdog 的 do_dump 包裝。"""
    watchdog_reset(f"do_dump:{label}")
    try:
        _check_watchdog_triggered()
        result = do_dump(d, label=label)
        _check_watchdog_triggered()
        return result
    finally:
        watchdog_stop()


def round2_loop(d_ref, tabs_list, name_to_symbol, symbol_order):
    lp("\n" + "=" * 60)
    lp("💉 ROUND 2：無限迴圈  (25s Watchdog Docker重建版)")
    lp("=" * 60)

    if not tabs_list: return

    loop_count = 0
    rebuild_count = 0
    max_rebuilds_per_cycle = 1
    last_klines_signature = None
    stale_count = 0
    MAX_STALE_COUNT = 15
    all_symbol_latest = {}
    d = d_ref[0]
    pending_market_switch = None
    tab_history = defaultdict(list)
    consecutive_alerts = 0
    _recovery_in_progress = False   # 三級修復防重入

    # ── 未完成 tab 追蹤 ──────────────────────────────────────────
    # 每輪開始時 remaining_tabs 複製自 tabs_list；
    # watchdog 觸發後從 resume_from_tab 繼續（含當前卡住的 tab）。
    resume_from_tab = None   # None = 從頭開始；str = 從此 tab 開始（含）

    def _do_market_switch(new_mode):
        global MARKET_MODE
        lp(f"🔄 [市場切換] {MARKET_MODE} -> {new_mode}，pm2 restart 交由啟動期以時間重新判定")
        append_log("MARKET_SWITCH", f"{MARKET_MODE} -> {new_mode} via pm2 restart")
        watchdog_stop()
        try:
            subprocess.Popen("pm2 restart futu.py", shell=True)
            time.sleep(0.2)
        except Exception as e:
            lp(f"   ⚠️ pm2 restart 指令異常: {e}")
        os._exit(0)

    def _reconnect_after_watchdog():
        """Watchdog 已重建 Docker，重新連接 uiautomator2 並進入 App。"""
        nonlocal d
        lp("🔄 [Watchdog後重連] 等待設備就緒...")
        if not wait_device_ready(timeout=120, check_interval=2):
            lp("❌ [Watchdog後重連] 設備超時，pm2 restart")
            _do_market_switch(MARKET_MODE)
        lp("🔄 [Watchdog後重連] 重新連接 uiautomator2...")
        try:
            new_d = u2.connect(DEVICE_ID)
            new_d.implicitly_wait(10.0)
            try:
                new_d.jsonrpc.setConfigurator({"waitForIdleTimeout": 0, "waitForSelectorTimeout": 0})
            except:
                pass
            d = new_d
            d_ref[0] = new_d
        except Exception as e:
            lp(f"❌ [Watchdog後重連] uiautomator2 連接失敗: {e}，pm2 restart")
            _do_market_switch(MARKET_MODE)
        lp("🔄 [Watchdog後重連] 啟動 App 並進入 K 線頁...")
        try:
            restart_app_and_enter_list(d)
        except Exception as e:
            lp(f"⚠️ [Watchdog後重連] restart_app_and_enter_list 異常: {e}")
        _watchdog_triggered.clear()
        lp("✅ [Watchdog後重連] 完成，從卡住的 tab 繼續。")

    while True:
        current_mode = get_market_mode()
        loop_has_alert = False

        if current_mode == "IDLE":
            pending_market_switch = None
            lp("🛑 休市，等待 60s..."); time.sleep(60); continue

        if current_mode != MARKET_MODE and FORCE_MARKET == "AUTO":
            _do_market_switch(current_mode); continue

        if pending_market_switch and pending_market_switch != MARKET_MODE:
            _do_market_switch(pending_market_switch); continue

        loop_count += 1
        lp(f"\n{'='*60}\n🔄 Loop #{loop_count}  {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}  市場={MARKET_MODE}\n{'='*60}")
        append_log("LOOP_START", f"Loop #{loop_count}")

        # 決定本輪從哪個 tab 開始
        if resume_from_tab and resume_from_tab in tabs_list:
            start_idx = tabs_list.index(resume_from_tab)
            lp(f"   ↩️ [Watchdog恢復] 從 tab={resume_from_tab} (idx={start_idx}) 繼續")
        else:
            start_idx = 0
            resume_from_tab = None

        loop_success, all_klines, tab_fail_count = False, [], 0

        try:
            for tab_idx, tab_name in enumerate(tabs_list):
                # 跳過已完成的 tab（watchdog 恢復時使用）
                if tab_idx < start_idx:
                    continue

                mid_check = get_market_mode()

                if mid_check != MARKET_MODE and FORCE_MARKET == "AUTO":
                    lp(f"   ⏰ 偵測到市場切換 ({MARKET_MODE} -> {mid_check})，立即 pm2 restart")
                    _do_market_switch(mid_check)

                if mid_check == "IDLE":
                    lp("   ⏰ 檢測到休市時間，提早結束本輪 tab 收集並保存數據...")
                    pending_market_switch = "IDLE"
                    break

                lp(f"\n── Tab: {tab_name} ──")
                append_log("TAB_START", f"Tab: {tab_name}")

                # ── switch_tab (帶 watchdog) ──────────────────────
                try:
                    sw_time = switch_tab_watched(d, tab_name)
                    lp(f"   ✅ 切換耗時: {sw_time:.2f}s")
                    tab_fail_count = 0
                except WatchdogTriggered:
                    lp(f"   🚨 [Watchdog] switch_tab:{tab_name} 觸發 Docker 重建，重連後從此 tab 繼續...")
                    append_log("WATCHDOG_RESUME", f"Resuming from tab={tab_name} after switch_tab")
                    resume_from_tab = tab_name
                    _reconnect_after_watchdog()
                    # 重連後跳出本輪 tab 迴圈，重新開始新一輪（resume_from_tab 已設定）
                    raise Exception("watchdog_docker_rebuild")
                except RuntimeError as e:
                    # ★ v8.10 漏判防護：RuntimeError 可能是 watchdog 重建 Docker 的副作用
                    if _watchdog_triggered.is_set():
                        lp(f"   🚨 [Watchdog漏判修正] switch_tab 失敗實為 Docker 已重建，走重連流程...")
                        append_log("WATCHDOG_RESUME", f"RuntimeError converted, tab={tab_name}")
                        resume_from_tab = tab_name
                        _reconnect_after_watchdog()
                        raise Exception("watchdog_docker_rebuild")
                    lp(f"   ❌ {tab_name} 切換失敗: {e}")
                    tab_fail_count += 1
                    if tab_fail_count >= max(1, len(tabs_list) // 2):
                        fm = get_market_mode()
                        if fm != MARKET_MODE and FORCE_MARKET == "AUTO":
                            lp(f"   ⏰ 失敗超過半數，且檢測到市場切換 ({MARKET_MODE} -> {fm})，提早結束本輪...")
                            pending_market_switch = fm
                            break
                        raise Exception(f"too_many_tab_failures")
                    if not find_pid(): raise Exception("switch_fail_no_pid")
                    continue

                # ── UIA 劣化偵測（三級修復入口，v8.10）──────────────
                degraded, _sw, _avg = is_switch_degraded(sw_time)
                if degraded and not _recovery_in_progress:
                    lp(f"   ⚠️ [劣化偵測] switch {_sw:.2f}s > avg {_avg:.2f}s ×{_SWITCH_DEGRADED_RATIO}，觸發三級修復")
                    append_log("DEGRADATION_DETECTED", f"sw={_sw:.2f}s avg={_avg:.2f}s tab={tab_name}")
                    _recovery_in_progress = True
                    try:
                        d = three_level_recovery(d, d_ref,
                            reason=f"switch_degraded tab={tab_name} sw={_sw:.2f}s avg={_avg:.2f}s")
                    finally:
                        _recovery_in_progress = False
                    
                    # 修復後重切當前 tab（確保本 tab 資料零遺漏）
                    try:
                        sw_time2 = switch_tab(d, tab_name)
                        lp(f"   🔄 [修復後重切] {tab_name} 耗時: {sw_time2:.2f}s")
                        record_switch_latency(sw_time2)
                    except Exception as _re:
                        lp(f"   ⚠️ [修復後重切] 失敗: {_re}，繼續下一 tab")
                        continue
                else:
                    record_switch_latency(sw_time)
                # ── 劣化偵測結束 ──────────────────────────────────

                # ── do_dump (帶 watchdog) ─────────────────────────
                t_dump_start = time.time()
                try:
                    xml = do_dump_watched(d, label=tab_name)
                except WatchdogTriggered:
                    lp(f"   🚨 [Watchdog] do_dump:{tab_name} 觸發 Docker 重建，重連後從此 tab 繼續...")
                    append_log("WATCHDOG_RESUME", f"Resuming from tab={tab_name} after do_dump")
                    resume_from_tab = tab_name
                    _reconnect_after_watchdog()
                    raise Exception("watchdog_docker_rebuild")

                dump_cost = time.time() - t_dump_start

                if not xml: continue

                # dump latency 僅記錄（警報已被 switch 劣化三級修復取代，效能大幅提升）
                history = tab_history[tab_name]
                history.append(dump_cost)
                if len(history) > 30: history.pop(0)

                tab_klines = parse_klines_from_xml_new(xml, name_to_symbol)
                all_klines.extend(tab_klines)
                lp(f"   ✅ {tab_name}: {len(tab_klines)} 筆 (Dump: {dump_cost:.2f}s)")
                append_log("TAB_END", f"Tab: {tab_name}, Cost: {dump_cost:.2f}s, Items: {len(tab_klines)}")

            # ── 本輪所有 tab 完成後清除 resume 標記 ──────────────
            resume_from_tab = None

            end_mode = get_market_mode()
            if end_mode == "IDLE": lp("🛑 [輪結束] 休市")
            elif end_mode != MARKET_MODE and FORCE_MARKET == "AUTO":
                if not pending_market_switch:
                    pending_market_switch = end_mode

            lp(f"\n🔄 彙整數據... ✅ {len(all_klines)} 筆")

            cur_signature = frozenset((item["symbol"], item["data"].get("T", "")) for item in all_klines)

            if cur_signature and cur_signature == last_klines_signature:
                stale_count += 1
                lp(f"   ⚠️ [停滯] {stale_count}/{MAX_STALE_COUNT}")
                append_log("STALE_ALERT", f"Stale: {stale_count}/{MAX_STALE_COUNT}")
                if stale_count >= MAX_STALE_COUNT:
                    stale_count, last_klines_signature, all_symbol_latest = 0, None, {}
                    raise Exception("stale_detection")
                loop_success = True; rebuild_count = 0; gc.collect()
                restart_app_and_enter_list(d)
                continue

            last_klines_signature, stale_count = cur_signature, 0

            unique_loop_klines = {}
            for item in all_klines:
                sym = item["symbol"]
                t_val = item["data"].get("T", "")
                key = f"{sym}_{t_val}"
                if key not in unique_loop_klines:
                    unique_loop_klines[key] = item

            new_klines = sorted(unique_loop_klines.values(), key=lambda x: symbol_order.get(x["symbol"], 999999))

            for item in all_klines:
                sym, T = item["symbol"], item["data"].get("T", "")
                if sym not in all_symbol_latest: all_symbol_latest[sym] = item
                else:
                    try:
                        if int(T if T else 0) >= int(all_symbol_latest[sym]["data"].get("T", "0") or "0"):
                            all_symbol_latest[sym] = item
                    except: all_symbol_latest[sym] = item

            renew_list = sorted(list(all_symbol_latest.values()), key=lambda x: symbol_order.get(x["symbol"], 999999))

            with open(ROUND2_FILE, 'w', encoding='utf-8') as f: json.dump(new_klines, f, ensure_ascii=False, indent=2)
            with open(ROUND2_ALL_FILE, 'w', encoding='utf-8') as f: json.dump(renew_list, f, ensure_ascii=False, indent=2)
            webhook_notify(ROUND2_FILE)
            lp(f"✅ Loop #{loop_count} 完成  本輪正序輸出={len(new_klines)}  全量最新={len(renew_list)}")
            append_log("LOOP_END", f"Loop #{loop_count} finished. Current Loop items: {len(new_klines)}")

            loop_success = True; rebuild_count = 0; gc.collect()

            consecutive_alerts = 0
            lp("\n   ✅ 本輪順暢，同 Session 繼續下一輪\n")

        except Exception as e:
            err_msg = str(e)
            lp(f"\n❌ Loop #{loop_count} 異常: {err_msg[:120]}")
            append_log("ERROR", err_msg[:120])

            # ★ v8.10 最後防線：任何異常若伴隨 watchdog 已觸發，一律轉為重連流程
            if "watchdog_docker_rebuild" not in err_msg and _watchdog_triggered.is_set():
                lp("   🚨 [Watchdog漏判防護] 異常期間偵測到 Docker 已重建，強制重連...")
                try:
                    resume_from_tab = tab_name
                except NameError:
                    resume_from_tab = None
                _reconnect_after_watchdog()
                err_msg = "watchdog_docker_rebuild"

            # watchdog 重建後：重連已在 _reconnect_after_watchdog() 完成，
            # 直接 continue 進入下一輪（resume_from_tab 已設定好）
            if "watchdog_docker_rebuild" in err_msg:
                rebuild_count = 0   # watchdog 重建視為基礎設施修復，不計入 rebuild_count
                loop_success = True
                lp("   ↩️ [Watchdog] 進入下一輪，從卡住的 tab 繼續...")
                continue

            if "tab_fail_market_mismatch" in err_msg: time.sleep(2); continue
            loop_success = False

        if not loop_success:
            mid_check = get_market_mode()
            if mid_check != MARKET_MODE and FORCE_MARKET == "AUTO":
                lp(f"⚠️ 修復時發現市場已變 ({MARKET_MODE} -> {mid_check})，pm2 restart 換場")
                _do_market_switch(mid_check)
            rebuild_count += 1
            lp(f"⚠️ 失敗: {rebuild_count}/{max_rebuilds_per_cycle}")
            if rebuild_count > max_rebuilds_per_cycle:
                lp("⚠️ 連續失敗超過上限，pm2 restart 重開")
                _do_market_switch(MARKET_MODE)
            try:
                stale_count, last_klines_signature, all_symbol_latest = 0, None, {}
                resume_from_tab = None   # 普通失敗從頭開始
                restart_app_and_enter_list(d)
                continue
            except Exception as _e2:
                lp(f"⚠️ 修復重啟 App 亦失敗: {_e2}，pm2 restart")
                _do_market_switch(MARKET_MODE)

# ══════════════════════════════════════════
# 主程式
# ══════════════════════════════════════════
def setup_and_run():
    global DOCKER_SHM, MARKET_MODE, FORCE_MARKET, KLINE_BAR_COUNT

    args = parse_args()
    FORCE_MARKET = args.market
    if args.bars: KLINE_BAR_COUNT = args.bars
    if args.reset_all:
        for _f in (ROUND0_CACHE_HK_FILE, ROUND0_CACHE_US_FILE, ROUND0_FILE, ROUND0_CACHE_META_FILE):
            try:
                if os.path.exists(_f): os.remove(_f)
            except: pass

    DOCKER_SHM = f"/dev/shm/{DOCKER_NAME}"

    lp("=" * 60)
    lp("🚀 Kline Probe 啟動 (v8.9.6 25s Watchdog Docker重建版)")
    lp(f"   市場: {FORCE_MARKET}")
    lp(f"   極限優化: 物理雙資料夾切換, CoW秒級快照, 1.0核滿載鎖定")
    lp(f"   Watchdog: 任意操作超過 {WATCHDOG_TIMEOUT}s 觸發 Docker 重建並從卡住 tab 繼續")
    lp("=" * 60)
    append_log("START", f"Script started. Market: {FORCE_MARKET}")

    mode = get_market_mode()
    while mode == "IDLE":
        lp("🛑 休市，等待 60s...")
        time.sleep(60)
        mode = get_market_mode()
    MARKET_MODE = mode
    lp(f"🕒 市場: {MARKET_MODE}")

    docker_recovery()
    if not wait_device_ready(timeout=120, check_interval=2): return

    d = u2.connect(DEVICE_ID)
    d.implicitly_wait(10.0)
    try: d.jsonrpc.setConfigurator({"waitForIdleTimeout": 0, "waitForSelectorTimeout": 0})
    except: pass

    if args.reset:
        cache_file_to_reset = get_round0_cache_file(MARKET_MODE)
        try:
            if os.path.exists(cache_file_to_reset):
                os.remove(cache_file_to_reset)
                lp(f"🧹 --reset: 已刪除 {MARKET_MODE} R0 cache: {cache_file_to_reset}")
        except: pass

    c_tabs, name_to_symbol, symbol_order, cache_ok = [], {}, {}, False

    cache_file = get_round0_cache_file(MARKET_MODE) if args.reset else ensure_round0_cache_from_legacy(MARKET_MODE)
    if os.path.exists(cache_file):
        try:
            with open(cache_file, 'r', encoding='utf-8') as f: full_cache = json.load(f)
            if full_cache.get("market") == MARKET_MODE and full_cache.get("tabs"):
                c_tabs = full_cache["tabs"]
                for idx, row in enumerate(full_cache.get("data", [])):
                    if len(row) >= 2:
                        c, n = row[0], row[1]
                        cln  = re.sub(r'\s*[(（](?:期貨)?主連 [^)）]*[)）]', '', n).strip()
                        name_to_symbol[cln] = c; name_to_symbol[n] = c
                        symbol_order[c] = idx
                cache_ok = True
                lp(f"\n🎉 找到 {MARKET_MODE} R0 緩存 ({len(c_tabs)} tabs, {len(symbol_order)} 股票): {cache_file}")
                try: refresh_round0_public_file()
                except: pass
        except: pass

    if cache_ok:
        ensure_app_running(d, force_restart=False)
        try:
            btn = d(resourceId=RID_HDR_LIST)
            if btn.exists(timeout=3.0): btn.click(); time.sleep(2)
        except: pass
        round2_loop([d], c_tabs, name_to_symbol, symbol_order)
        return

    ensure_app_running(d, force_restart=True)
    tabs, symbol_map, symbol_order = round0_collect_name_code_mapping(d)
    if not tabs: return
    time.sleep(2)

    try:
        btn = d(resourceId=RID_HDR_LIST)
        if btn.exists(timeout=5.0): btn.click(); time.sleep(2.0)
    except: pass

    round2_loop([d], tabs, symbol_map, symbol_order)
    lp("✅ 結束")

if __name__ == "__main__":
    setup_and_run()
