

#!/usr/bin/env python3
"""
kline_probe.py  (merged v8.9 Ultimate - 終極完美無錯版 - 1C6G 專機 30FPS 版)
基礎設施層：物理目錄隔離 / CoW 秒級快照 / 1.0核實體鎖定 (專機專用) / 種子預固化免初始
應用層：ROUND0 → (移除ROUND1) → ROUND2 無限迴圈 (同 Session 無縫連跑)

更新記錄：
  v1~v22. 核心特徵與環境隔離、修復浮點數精度。
  v23~24. 鎖定 JSON 輸出順序，分離 new/renew，修復期貨主連名稱匹配。
  v25. (v8.8) 取消跨輪 T 值去重防止同分鐘內成交量跳動遺失(找漏)。
  v26. (v8.9) 【黑核心修復】找完資料後，先將 T1~T5 倒敘轉為順序 (T5->T1)，再去重輸出，
              確保後端依時間軸「由舊到新」順暢接收，徹底解決後端提早停止的問題。
"""

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)

# ══════════════════════════════════════════
# 市場控制與 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 240 <= ny_min < 1200:
        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'[(（][^)）]*[)）]', '', 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*[(（][^)）]*[)）]\s*', ' ', name).strip()
    name = re.sub(r'\s*期貨主連\s*$', '', name).strip()
    name = re.sub(r'\s*主連\s*$',    '', name).strip()
    name = re.sub(r'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, max_depth=MAX_DEPTH)
            if not xml: break
                
            popped = False
            if "以後再說" in xml: d(text="以後再說").click(timeout=1); popped = True
            elif "cn.futu.trader:id/btn_cancel" in xml: d(resourceId="cn.futu.trader:id/btn_cancel").click(timeout=1); popped = True
            elif 'content-desc="關閉"' in xml: d(description="關閉").click(timeout=1); popped = True
            else:
                for cid in ["cn.futu.trader:id/iv_close", "cn.futu.trader:id/btn_close", "cn.futu.trader:id/close_btn", "cn.futu.trader:id/tv_close"]:
                    if cid in xml:
                        d(resourceId=cid).click(timeout=1)
                        popped = True
                        break

            if popped: time.sleep(0.5); continue
            else: break
        except: 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 線頁)")

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

    for i in range(1, 4):
        if _one_attempt(i, 3): return time.perf_counter() - t0

    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:
                    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)} 個）")

    # 【修改後】從主腦讀取中央 ow.json
    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:
                    # 若 ow.json 匹配到 code，執行更新
                    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:
        # 這裡的 except 必須跟 try 對齊
        lp(f"   ⚠️ 讀取遠端 ow.json 失敗: {e}")

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

    with open(ROUND0_FILE, 'w', encoding='utf-8') as f:
        json.dump(combined_data, f, ensure_ascii=False, indent=2)

    lp(f"💾 ROUND 0 已存: {ROUND0_FILE} ({len(all_stocks)} 股票)")
    webhook_notify_mapping(ROUND0_FILE)
    append_log("R0_END", f"Collected {len(all_stocks)} stocks")

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

    return list(done_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'[(（][^)）]*[)）]', '', 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

            # 【黑核心修復】倒敘轉順序 (T5走到T1)
            # 富途圖例抓出來是 T1(最新) -> T5(最舊)。後端需要 最舊 -> 最新，才能連續接收不斷線！
            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

def round2_loop(d_ref, tabs_list, name_to_symbol, symbol_order):
    lp("\n" + "=" * 60)
    lp("💉 ROUND 2：無限迴圈")
    lp("=" * 60)

    if not tabs_list: return

    loop_count = 0
    rebuild_count = 0
    max_rebuilds_per_cycle = 3
    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

    def _do_market_switch(new_mode):
        nonlocal d, tabs_list, name_to_symbol, symbol_order, last_klines_signature, all_symbol_latest, stale_count, rebuild_count, pending_market_switch, tab_history, consecutive_alerts
        global MARKET_MODE

        lp(f"🔄 [市場切換 / 深度重建] {MARKET_MODE} → {new_mode}")
        MARKET_MODE = new_mode
        pending_market_switch = None

        docker_recovery()
        if not wait_device_ready(timeout=90, check_interval=2):
            time.sleep(10)
            return False

        try:
            d = u2.connect(DEVICE_ID)
            d.implicitly_wait(10.0)
            d_ref[0] = d
        except: return False

        cache_success = False
        if os.path.exists(ROUND0_FILE):
            try:
                with open(ROUND0_FILE, 'r', encoding='utf-8') as f: full_cache = json.load(f)
                if full_cache.get("market") == new_mode and full_cache.get("tabs"):
                    tabs_list = full_cache["tabs"]
                    name_to_symbol.clear()
                    symbol_order.clear()
                    for idx, row in enumerate(full_cache.get("data", [])):
                        if len(row) >= 2:
                            c, n = row[0], row[1]
                            cln = re.sub(r'[(（][^)）]*[)）]', '', n).strip()
                            name_to_symbol[cln] = c; name_to_symbol[n] = c
                            symbol_order[c] = idx
                    cache_success = True
                    lp(f"   ✅ {new_mode} R0 緩存讀取成功")
            except: pass

        if not cache_success:
            ensure_app_running(d, force_restart=True) 
            new_tabs, new_map, new_order = round0_collect_name_code_mapping(d)
            if new_tabs:
                tabs_list, name_to_symbol, symbol_order = new_tabs, new_map, new_order
        else:
            ensure_app_running(d, force_restart=False)

        last_klines_signature, all_symbol_latest, stale_count, rebuild_count, consecutive_alerts = None, {}, 0, 0, 0
        tab_history.clear()

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

    while True:
        current_mode = get_market_mode()
        loop_has_alert = False

        if current_mode == "IDLE":
            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}")

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

        try:
            for tab_name in tabs_list:
                mid_check = get_market_mode()
                if mid_check != MARKET_MODE and FORCE_MARKET == "AUTO":
                    pending_market_switch = mid_check; raise Exception("market_changed_mid_loop")
                if mid_check == "IDLE": raise Exception("idle_mid_loop")

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

                try:
                    sw_time = switch_tab(d, tab_name)
                    lp(f"   ✅ 切換耗時: {sw_time:.2f}s")
                    tab_fail_count = 0
                except RuntimeError as e:
                    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": pending_market_switch = fm; raise Exception("tab_fail_market_mismatch")
                        raise Exception(f"too_many_tab_failures")
                    if not find_pid(): raise Exception("switch_fail_no_pid")
                    continue

                t_dump_start = time.time()
                xml = do_dump(d, label=tab_name)
                dump_cost = time.time() - t_dump_start

                if not xml: continue

                history = tab_history[tab_name]
                if len(history) >= 30:
                    past_avg = sum(history) / len(history)
                    if dump_cost > (past_avg * 1.5) and dump_cost > 30.0:
                        lp(f"   🚨 [效能警報] {tab_name} Dump ({dump_cost:.2f}s) 超出均值 ({past_avg:.2f}s) 50% 且 > 30s！")
                        loop_has_alert = True
                
                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)}")

            end_mode = get_market_mode()
            if end_mode == "IDLE": lp("🛑 [輪結束] 休市")
            elif end_mode != MARKET_MODE and FORCE_MARKET == "AUTO": 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

            # 1. 處理 new_klines (內部去重 + 保持 T5->T1 的正向時間序列)
            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
            
            # 強制依照 R0 的絕對順序排序，由於 Sorted 是穩定排序 (Stable Sort)，
            # 經過 bars.reverse() 後的 T5->T1 順序會被完美保留，確保後端按時間軸舊到新讀取。
            new_klines = sorted(unique_loop_klines.values(), key=lambda x: symbol_order.get(x["symbol"], 999999))

            # 2. 處理 renew_klines (留住一直以來的最新 data，累計並鎖定排序)
            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()

            if loop_has_alert:
                consecutive_alerts += 1
                if consecutive_alerts >= 3:
                    lp(f"\n🔄 [效能降級] 連續 {consecutive_alerts} 輪劣化，觸發深度重建...")
                    _do_market_switch(MARKET_MODE)
                else:
                    lp(f"\n🔄 [效能降級] 第 {consecutive_alerts} 輪劣化，重啟 App...")
                    restart_app_and_enter_list(d)
            else:
                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])
            if any(k in err_msg for k in ["market_changed_mid_loop", "tab_fail_market_mismatch", "idle_mid_loop"]): time.sleep(2); continue
            loop_success = False

        if not loop_success:
            rebuild_count += 1
            lp(f"⚠️ 失敗: {rebuild_count}/{max_rebuilds_per_cycle}")
            if rebuild_count > max_rebuilds_per_cycle: time.sleep(30); rebuild_count = 0
            
            try:
                stale_count, last_klines_signature, all_symbol_latest = 0, None, {}
                restart_app_and_enter_list(d)
                continue
            except: pass
            
            _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 and os.path.exists(ROUND0_FILE): os.remove(ROUND0_FILE)

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

    lp("=" * 60)
    lp("🚀 Kline Probe 啟動 (v8.9 終極完美無錯版)")
    lp(f"   市場: {FORCE_MARKET}")
    lp(f"   極限優化: 物理雙資料夾切換, CoW秒級快照, 1.0核滿載鎖定")
    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 and os.path.exists(ROUND0_FILE):
        try:
            with open(ROUND0_FILE, 'r', encoding='utf-8') as f:
                if json.load(f).get("market") == MARKET_MODE: os.remove(ROUND0_FILE)
        except: pass

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

    if os.path.exists(ROUND0_FILE):
        try:
            with open(ROUND0_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'[(（][^)）]*[)）]', '', 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)} 股票)")
        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()
