import sys
import os
import json
import re
import subprocess
import threading
import time

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(BASE_DIR, "pyenv"))

import ctypes as _ctypes

_capi_dir = os.path.join(BASE_DIR, "pyenv", "onnxruntime", "capi")
if os.path.isdir(_capi_dir):
    os.add_dll_directory(_capi_dir)
    for _dll in sorted(os.listdir(_capi_dir)):
        _low = _dll.lower()
        if _low.endswith((".dll", ".pyd")) and _low.startswith(
            ("cublas", "cudart", "cudnn", "cufft", "nvrtc", "curand")
        ):
            try:
                _ctypes.WinDLL(os.path.join(_capi_dir, _dll))
            except Exception:
                pass

import cv2
import numpy as np
from rapidocr_onnxruntime import RapidOCR

ADB = os.path.join(BASE_DIR, "platform-tools", "adb.exe")
DEVICE = None
DISPLAY_HWC = None
DISPLAY_ID = None
INPUT_DISPLAY_ID = None
PKG = "com.fenbi.android.leo"
DICT_PATH = os.path.join(BASE_DIR, "dict_core.json")
SYN_PATH = os.path.join(BASE_DIR, "synonyms.json")

RES_W, RES_H = 1080, 1920

OCR_SCALE = 0.5
TOPIC_X = (60, 1020)
TOPIC_Y = (500, 1050)
OPT_X = (120, 960)
OPT_Y = (1100, 1860)

TICK = 0.05
BTN_WAIT = 0.4
RECHECK_MIN = 2.0
CHG_RATIO = 0.02
GATE_MIN = 0.3
XR_FLASH = 0.008
XR_CALM = 0.002
XR_HOLD = 0.25
XR_MIN_WAIT = 0.9

POS_TAGS = {"n.", "v.", "vt.", "vi.", "a.", "adj.", "adv.", "ad.", "prep.", "conj.", "pron.", "num.", "art.", "int.", "aux.", "abbr."}

SCOREBOARD = re.compile(r"^\d+/\d+题$|^\d{1,2}[:：]\d{2}$|^比谁快$|^对手$|^我$|^你$|^胜利$|^失败$|^用时|^领先|^落后|^恭喜|^本局|^再接再厉|^再来|^再练一次|^重新开始|^继续挑战|^继续PK|^继续比赛|^开心收下|^去练习|^准备|^Ready")

POS_GROUPS = {"n.": "n", "v.": "v", "vt.": "v", "vi.": "v", "aux.": "v",
              "a.": "a", "adj.": "a", "adv.": "d", "ad.": "d", "prep.": "p",
              "conj.": "c", "pron.": "r", "num.": "m", "art.": "x",
              "int.": "x", "abbr.": "x"}

def set_ocr_device(device):
    from pathlib import Path
    import rapidocr_onnxruntime
    cfg_path = Path(rapidocr_onnxruntime.__file__).resolve().parent / "config.yaml"
    text = cfg_path.read_text(encoding="utf-8")
    new = "true" if device == "gpu" else "false"
    patched = re.sub(r"(?m)^(\s*use_cuda:)\s*(true|false)$",
                     lambda m: "{0} {1}".format(m.group(1), new), text)
    if patched != text:
        cfg_path.write_text(patched, encoding="utf-8")

def pos_of_text(txt):
    if not txt:
        return None
    m = re.match(r"^\s*(n\.|v\.|vt\.|vi\.|a\.|adj\.|adv\.|ad\.|prep\.|conj\.|pron\.|num\.|art\.|int\.|aux\.|abbr\.)", txt)
    if m:
        return POS_GROUPS[m.group(1)]
    return None

def fuzzy_key(w, keys):
    if w in keys:
        return w
    for k in keys:
        if abs(len(k) - len(w)) <= 1:
            diff = abs(len(k) - len(w)) + sum(a != b for a, b in zip(k, w))
            if diff <= 1:
                return k
    return w

def stem_variants(w):
    if len(w) < 4:
        return []
    out = []
    lw = w
    if lw.endswith("ies") and len(lw) > 4:
        out.append(lw[:-3] + "y")
    elif lw.endswith("es") and len(lw) > 3:
        out.append(lw[:-2])
    if lw.endswith("ing"):
        base = lw[:-3]
        out.append(base)
        out.append(base + "e")
        if len(base) >= 3 and base[-1] == base[-2]:
            out.append(base[:-1])
    elif lw.endswith("ed"):
        out.append(lw[:-2])
        out.append(lw[:-1])
    if lw.endswith("er") and len(lw) > 4:
        out.append(lw[:-2])
    if lw.endswith("est") and len(lw) > 5:
        out.append(lw[:-3])
    if lw.endswith("s") and not lw.endswith("ss") and len(lw) > 3:
        out.append(lw[:-1])
    seen = []
    for v in out:
        if v not in seen:
            seen.append(v)
    return seen

def dict_trans(w, d, keys):
    if w in d:
        return w, d[w]
    for v in stem_variants(w):
        if v in d:
            return v, d[v]
    k = fuzzy_key(w, keys)
    if k in d:
        return k, d[k]
    return None, ""

def fallback_order(topic_text, options, dtrans, direction, scored):
    tpos = None
    if direction == "en2cn":
        if dtrans:
            tpos = pos_of_text(dtrans)
    else:
        tpos = pos_of_text(topic_text)
    cands = [sc[1] for sc in scored]
    if tpos:
        same = [o for o in cands if pos_of_text(o[6]) == tpos]
        rest = [o for o in cands if pos_of_text(o[6]) != tpos]
        cands = same + rest or cands
    if cands:
        return cands
    return list(options) or None

def is_word_text(t):
    return bool(re.fullmatch(r"[a-zA-Z][a-zA-Z'\-]*", t))

def has_cn(t):
    return bool(re.search(r"[\u4e00-\u9fff]", t))

RESTART_LABELS = ("再练一次", "再来一次", "重新开始", "继续挑战", "继续PK", "继续比赛")
CLOSE_LABELS = ("开心收下",)
BACK_LABELS = ("去练习",)

def adb_devices():
    r = subprocess.run([ADB, "devices"], capture_output=True, text=True, timeout=30)
    devs = []
    for line in r.stdout.splitlines()[1:]:
        line = line.strip()
        if not line or "\t" not in line:
            continue
        s, st = line.split("\t")[:2]
        if st.strip() == "device":
            devs.append(s.strip())
    return devs

def find_device():
    devs = adb_devices()
    if not devs:
        return None
    for d in devs:
        if d == "emulator-5558":
            return d
    for d in devs:
        if d.startswith("emulator-"):
            return d
    return devs[0]

def _display_for_pkg(serial, pkg):
    r = subprocess.run([ADB, "-s", serial, "shell", "dumpsys", "SurfaceFlinger", "--list"],
                       capture_output=True, text=True, timeout=30)
    parent_of = {}
    display_of = {}
    targets = []
    for line in r.stdout.splitlines():
        line = line.strip()
        m = re.search(r"Display \d+ name=\"([^\"]+)\".*?#(\d+)", line)
        if m:
            display_of[int(m.group(2))] = m.group(1)
            continue
        m = re.search(r"#(\d+)", line)
        if not m:
            continue
        nid = int(m.group(1))
        mp = re.search(r"parentId=(\d+)", line)
        if mp:
            parent_of[nid] = int(mp.group(1))
        if pkg in line:
            targets.append((nid, mp is not None))
    for nid, has_parent in targets:
        if not has_parent:
            continue
        cur = nid
        seen = set()
        while cur not in seen:
            seen.add(cur)
            if cur in display_of:
                return display_of[cur]
            cur = parent_of.get(cur)
            if cur is None:
                break
    return None

def _input_display_for_long(serial, long_id):
    r = subprocess.run([ADB, "-s", serial, "shell", "dumpsys", "display"],
                       capture_output=True, text=True, timeout=30)
    for m in re.finditer(r'DisplayInfo\{"[^"]+",\s*displayId (\d+),.*?uniqueId "local:(\d+)"', r.stdout):
        if m.group(2) == str(long_id):
            return int(m.group(1))
    return None

def find_display(serial, pkg=PKG):
    global DISPLAY_HWC, DISPLAY_ID, INPUT_DISPLAY_ID
    info = {}
    r = subprocess.run([ADB, "-s", serial, "shell", "dumpsys", "SurfaceFlinger", "--display-id"],
                       capture_output=True, text=True, timeout=30)
    for line in r.stdout.splitlines():
        m = re.search(r"Display (\d+)\s*\(HWC display (\d+)\).*?displayName=\"([^\"]+)\"", line)
        if m:
            info[m.group(3)] = (m.group(1), m.group(2))
    disp_name = _display_for_pkg(serial, pkg)
    hit = info.get(disp_name) if disp_name else None
    if hit is None:
        for name, ids_ in info.items():
            if name.startswith("mumuscreen"):
                hit = ids_
                break
    if hit is None:
        DISPLAY_HWC = DISPLAY_ID = INPUT_DISPLAY_ID = None
        return None
    DISPLAY_HWC, DISPLAY_ID = hit
    INPUT_DISPLAY_ID = _input_display_for_long(serial, DISPLAY_HWC)
    return (DISPLAY_HWC, DISPLAY_ID, disp_name)

def recheck_device():
    global DEVICE
    d = find_device()
    if d is None:
        return False
    if d != DEVICE or DISPLAY_HWC is None:
        DEVICE = d
        find_display(d)
        print(f"[adb] redetect: device={DEVICE} display={DISPLAY_HWC} input={INPUT_DISPLAY_ID} hwc={DISPLAY_ID}")
    return True

def adb(*args):
    return subprocess.run([ADB, "-s", DEVICE, *args], capture_output=True, text=True, timeout=30)

def grab_img():
    r = subprocess.run([ADB, "-s", DEVICE, "exec-out", "screencap", "-p", "-d", DISPLAY_HWC],
                       capture_output=True, timeout=30)
    buf = r.stdout
    if r.returncode != 0 or len(buf) < 16:
        return None
    if buf[:4] == b"\x89PNG":
        return cv2.imdecode(np.frombuffer(buf, np.uint8), cv2.IMREAD_COLOR)
    w = int.from_bytes(buf[0:4], "little")
    h = int.from_bytes(buf[4:8], "little")
    fmt = int.from_bytes(buf[8:12], "little")
    if w != RES_W or h != RES_H:
        return None
    if fmt in (1, 2):
        arr = np.frombuffer(buf, np.uint8, offset=16).reshape(h, w, 4)
        return arr[:, :, :3][:, :, ::-1].copy()
    if fmt == 3:
        arr = np.frombuffer(buf, np.uint8, offset=16).reshape(h, w, 3)
        return arr[:, :, ::-1].copy()
    return None

SIGN_SCALE = 0.25

def topic_signature(img):
    crop = img[TOPIC_Y[0]:TOPIC_Y[1], TOPIC_X[0]:TOPIC_X[1]]
    gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
    return cv2.resize(gray, None, fx=SIGN_SCALE, fy=SIGN_SCALE, interpolation=cv2.INTER_AREA)

def sig_diff(a, b):
    if a is None or b is None or a.shape != b.shape:
        return 999.0
    return float(np.abs(a.astype(np.int16) - b.astype(np.int16)).mean())

def chg_ratio(a, b, th=25):
    if a is None or b is None or a.shape != b.shape:
        return 1.0
    return float((np.abs(a.astype(np.int16) - b.astype(np.int16)) > th).mean())

def full_signature(img):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    return cv2.resize(gray, None, fx=SIGN_SCALE, fy=SIGN_SCALE, interpolation=cv2.INTER_AREA)

class Grabber:
    def __init__(self):
        self._img = None
        self._seq = 0
        self._taken = 0
        self._lock = threading.Lock()
        self._stop = False
        self._th = threading.Thread(target=self._loop, daemon=True)

    def _loop(self):
        while not self._stop:
            img = grab_img()
            if img is not None:
                with self._lock:
                    self._img = img
                    self._seq += 1
            time.sleep(0.01)

    def start(self):
        self._th.start()

    def stop(self):
        self._stop = True

    def grab(self, timeout=1.5):
        deadline = time.time() + timeout
        while True:
            with self._lock:
                if self._seq > self._taken:
                    self._taken = self._seq
                    return self._img
            if time.time() >= deadline:
                with self._lock:
                    return self._img
            time.sleep(0.02)

def split_chunks(text):
    parts = re.findall(r"[\u4e00-\u9fff][\u4e00-\u9fff、,，;；/·]*", text)
    out = []
    for p in parts:
        for seg in re.split(r"[、,，;；/·]", p):
            seg = seg.strip()
            if seg:
                out.append(seg)
    return out

def clean_translation(trans):
    t = trans
    for tag in POS_TAGS:
        t = t.replace(tag, " ")
    t = re.sub(r"\[网络\]|\[医\]|\[经\]|\[计\]|\[法\]|\[化\]|\[地名\]", " ", t)
    return t

def _overlap(c, oo):
    if c in oo or oo in c:
        return float(len(c))
    m = 0
    for i in range(len(c)):
        for j in range(len(oo)):
            if c[i] != oo[j]:
                continue
            k = 1
            while i + k < len(c) and j + k < len(oo) and c[i + k] == oo[j + k]:
                k += 1
            if k > m:
                m = k
    return m * 0.6 if m >= 2 else 0.0

def match_score(topic_text, trans):
    qs = split_chunks(topic_text)
    os_ = split_chunks(clean_translation(trans))
    if not qs or not os_:
        return 0.0
    total = sum(len(c) for c in qs)
    hit = 0
    for c in qs:
        hit += max(_overlap(c, oo) for oo in os_)
    return hit / max(1, total)

def ocr_region(engine, img, x0, x1, y0, y1):
    sub = img[max(0, y0):y1, max(0, x0):x1]
    if OCR_SCALE != 1.0:
        sub = cv2.resize(sub, None, fx=OCR_SCALE, fy=OCR_SCALE, interpolation=cv2.INTER_AREA)
    result = engine(sub)[0]
    items = []
    if result:
        inv = 1.0 / OCR_SCALE
        for r in result:
            box, text, c = r
            xs = [p[0] for p in box]
            ys = [p[1] for p in box]
            xo1, yo1, xo2, yo2 = int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))
            cx = int((xo1 + xo2) / 2 * inv) + x0
            cy = int((yo1 + yo2) / 2 * inv) + y0
            t = text.strip()
            if t:
                items.append((cx, cy, int(xo1 * inv) + x0, int(yo1 * inv) + y0,
                              int(xo2 * inv) + x0, int(yo2 * inv) + y0, t, float(c)))
    return items

def ocr_dual(engine, img):
    tp = img[TOPIC_Y[0]:TOPIC_Y[1], TOPIC_X[0]:TOPIC_X[1]]
    op = img[OPT_Y[0]:OPT_Y[1], OPT_X[0]:OPT_X[1]]
    if OCR_SCALE != 1.0:
        tp = cv2.resize(tp, None, fx=OCR_SCALE, fy=OCR_SCALE, interpolation=cv2.INTER_AREA)
        op = cv2.resize(op, None, fx=OCR_SCALE, fy=OCR_SCALE, interpolation=cv2.INTER_AREA)
    th, tw = tp.shape[:2]
    oh, ow = op.shape[:2]
    if ow < tw:
        pad = np.full((oh, tw - ow, 3), 255, np.uint8)
        op = np.hstack([op, pad])
    elif tw < ow:
        pad = np.full((th, ow - tw, 3), 255, np.uint8)
        tp = np.hstack([tp, pad])
        tw = ow
    gap_h = 20
    comb = np.vstack([tp, np.full((gap_h, tw, 3), 255, np.uint8), op])
    result = engine(comb)[0]
    inv = 1.0 / OCR_SCALE
    topic_items = []
    opt_items = []
    if result:
        for r in result:
            box, text, c = r
            xs = [p[0] for p in box]
            ys = [p[1] for p in box]
            xo1, yo1, xo2, yo2 = int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))
            t = text.strip()
            if not t or SCOREBOARD.match(t):
                continue
            if (yo1 + yo2) // 2 < th:
                cx = int((xo1 + xo2) / 2 * inv) + TOPIC_X[0]
                cy = int((yo1 + yo2) / 2 * inv) + TOPIC_Y[0]
                topic_items.append((cx, cy, int(xo1 * inv) + TOPIC_X[0], int(yo1 * inv) + TOPIC_Y[0],
                                    int(xo2 * inv) + TOPIC_X[0], int(yo2 * inv) + TOPIC_Y[0], t, float(c)))
            else:
                wp = th + gap_h
                cx = int((xo1 + xo2) / 2 * inv) + OPT_X[0]
                cy = int((yo1 + yo2) / 2 * inv) - int(wp * inv) + OPT_Y[0]
                opt_items.append((cx, cy, int(xo1 * inv) + OPT_X[0], int((yo1 - wp) * inv) + OPT_Y[0],
                                  int(xo2 * inv) + OPT_X[0], int((yo2 - wp) * inv) + OPT_Y[0], t, float(c)))
    if not any(has_cn(i[6]) or is_word_text(i[6]) for i in topic_items):
        r2 = engine(img[TOPIC_Y[0]:TOPIC_Y[1], TOPIC_X[0]:TOPIC_X[1]])[0]
        if r2:
            t2 = []
            for r in r2:
                box, text, c = r
                xs = [p[0] for p in box]
                ys = [p[1] for p in box]
                xo1, yo1, xo2, yo2 = int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))
                t = text.strip()
                if t and not SCOREBOARD.match(t):
                    t2.append(((xo1 + xo2) // 2 + TOPIC_X[0], (yo1 + yo2) // 2 + TOPIC_Y[0],
                               xo1 + TOPIC_X[0], yo1 + TOPIC_Y[0], xo2 + TOPIC_X[0], yo2 + TOPIC_Y[0], t, float(c)))
            if t2:
                topic_items = t2
    return topic_items, opt_items

def ocr_main(engine, img):
    return ocr_region(engine, img, 0, RES_W, 1150, RES_H)

def load_dict():
    with open(DICT_PATH, encoding="utf-8") as f:
        return json.load(f)

def save_json(path, obj):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(obj, f, ensure_ascii=False)

def load_syn():
    try:
        with open(SYN_PATH, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}

def normalize_word(w):
    return w.strip().lower().strip("'").replace("'s", "S")

def answer_once(engine, d, keys, img, syn=None, verbose=True):
    if img is None:
        if verbose:
            print("[!] cannot read image")
        return None
    topic_items, opt_items = ocr_dual(engine, img)
    if verbose:
        print("=" * 60)
    cn = [i for i in topic_items if has_cn(i[6])]
    if cn:
        topic = max(cn, key=lambda i: i[5] - i[3])
    else:
        ws = [i for i in topic_items if is_word_text(i[6])]
        if ws:
            topic = max(ws, key=lambda i: i[5] - i[3])
        else:
            topic = None
    if topic is None:
        if verbose:
            print("[!] no topic text detected")
        return None
    tt = topic[6]
    if has_cn(tt):
        direction = "cn2en"
        options = [i for i in opt_items if is_word_text(i[6])]
    else:
        direction = "en2cn"
        options = [i for i in opt_items if has_cn(i[6])]
    options = sorted(options, key=lambda o: o[1])
    if len(options) < 2:
        if verbose:
            print(f"[!] GHOST frame (only {len(options)} opt, topic='{tt}'), skip")
        return None
    if direction == "en2cn":
        qkey = fuzzy_key(re.sub(r"[^a-z]", "", normalize_word(tt)), keys)
    else:
        qkey = re.sub(r"[^\u4e00-\u9fffA-Za-z0-9]", "", tt)
    known = (syn or {}).get(f"{direction}|{qkey}") or []
    if verbose:
        print(f"DIR: {direction}  TOPIC: {tt}")
    scored = []
    dtrans = None
    if direction == "cn2en":
        for o in options:
            w = normalize_word(o[6])
            wk, trans = dict_trans(w, d, keys)
            s = match_score(tt, trans)
            if known and w in known:
                s = max(s, 1.001)
                if verbose:
                    print(f"  SYN-HIT {o[6]}")
            scored.append((s, o, w, trans))
            if verbose:
                print(f"  OPT {o[6]}  '{w}' -> '{wk}' score={s:.3f}  trans={trans[:50]}")
    elif direction == "en2cn":
        w = re.sub(r"[^a-z]", "", normalize_word(tt))
        wk, dtrans = dict_trans(w, d, keys)
        if verbose:
            print(f"  DICT {wk}: {dtrans[:100]}")
        for o in options:
            s = match_score(o[6], dtrans)
            if known and re.sub(r"\s+", "", o[6]) in known:
                s = max(s, 1.001)
                if verbose:
                    print(f"  SYN-HIT {o[6]}")
            scored.append((s, o, o[6], dtrans))
            if verbose:
                print(f"  OPT {o[6]} score={s:.3f}")
    else:
        if verbose:
            print("[!] unknown direction")
        return None
    if not scored:
        if verbose:
            print("[!] no option detected")
        return None
    scored.sort(key=lambda x: -x[0])
    best_s, best_o, best_w, best_trans = scored[0]
    opt_norm = re.sub(r"\s+", "", best_o[6])
    frame_key = (qkey, direction, opt_norm)
    if best_s <= 0:
        cands = fallback_order(tt, options, dtrans, direction, scored)
        if cands is None:
            if verbose:
                print("[!] no confident match, no fallback")
            return None
        if verbose:
            print("-> FALLBACK " + " | ".join(f"{o[6]}@({o[0]},{o[1]})" for o in cands))
        return cands, (qkey, direction, re.sub(r"\s+", "", cands[0][6]))
    if verbose:
        print(f"-> CHOOSE {best_o[6]} at ({best_o[0]},{best_o[1]}) score={best_s:.3f}")
    return [sc[1] for sc in scored], frame_key

def learn_word(d, keys, syn, key, tapped_text):
    qkey, direction, _ = key
    if direction == "cn2en":
        word = fuzzy_key(normalize_word(tapped_text), keys)
        trans = qkey
        syn_k = f"cn2en|{qkey}"
        syn_v = normalize_word(tapped_text)
    else:
        word = qkey
        trans = tapped_text
        syn_k = f"en2cn|{qkey}"
        syn_v = re.sub(r"\s+", "", tapped_text)
    if not word or not trans:
        return
    if direction == "en2cn" and not has_cn(trans):
        return
    lst = syn.get(syn_k) or []
    if syn_v not in lst:
        lst.append(syn_v)
        syn[syn_k] = lst
        save_json(SYN_PATH, syn)
        print(f"SYN {syn_k} += {syn_v}")
    if re.fullmatch(r"[a-zA-Z][a-zA-Z\-]*", word) and word not in d:
        keys.add(word)
        d[word] = trans
        save_json(DICT_PATH, d)
        print(f"LEARN {word} = {trans}")

def find_button(items):
    for labs, kind in ((RESTART_LABELS, "RESTART"), (CLOSE_LABELS, "CLOSE"), (BACK_LABELS, "BACK")):
        for lab in labs:
            for it in items:
                if lab in it[6] and it[1] > 1200:
                    return it, kind, lab
    return None, "", ""

def run_pk(engine, d, keys, syn, loop_pk):
    t0 = time.time()
    last_key = None
    attempt = 0
    last_tap = None
    last_tap_ts = 0.0
    last_tap_sig = None
    last_full_sig = None
    xr_seen = False
    xr_stable_at = 0.0
    xr_peak = 0.0
    xr_peak_at = 0.0
    gate_logged = False
    trace = []
    trace_done = False
    no_frame = 0
    grabber = Grabber()
    grabber.start()
    def log(msg):
        print(f"[+{time.time()-t0:6.1f}s] {msg}")
    while True:
        try:
            img = grabber.grab(timeout=0.12)
            if img is None:
                no_frame += 1
                if no_frame % 3 == 0:
                    if recheck_device():
                        no_frame = 0
                    else:
                        log("[!] adb device lost")
                time.sleep(0.15)
                continue
            sg = topic_signature(img)
            fs = full_signature(img)
            if last_tap_sig is not None:
                d_ = sig_diff(sg, last_tap_sig)
                cr_ = chg_ratio(sg, last_tap_sig)
                fd_ = sig_diff(fs, last_full_sig)
                fr_ = chg_ratio(fs, last_full_sig)
                if fr_ > xr_peak:
                    xr_peak = fr_
                    xr_peak_at = time.time() - last_tap_ts
                if not trace_done:
                    trace.append([round(time.time() - last_tap_ts, 2),
                                  round(d_, 2), round(fd_, 2),
                                  round(cr_, 4), round(fr_, 4)])
                    if len(trace) >= 200:
                        trace_done = True
                last_full_sig = fs
                early = False
                if not xr_seen and fr_ >= XR_FLASH:
                    xr_seen = True
                    xr_stable_at = 0.0
                if xr_seen:
                    if fr_ < XR_CALM:
                        if xr_stable_at == 0.0:
                            xr_stable_at = time.time()
                        if time.time() - xr_stable_at >= XR_HOLD and time.time() - last_tap_ts >= XR_MIN_WAIT:
                            early = True
                    else:
                        xr_stable_at = 0.0
                if (cr_ >= CHG_RATIO and time.time() - last_tap_ts >= GATE_MIN) or early or time.time() - last_tap_ts >= RECHECK_MIN:
                    if not gate_logged:
                        gate_logged = True
                        if early:
                            log(f"GATE early wait={time.time()-last_tap_ts:.2f}s peak={xr_peak:.4f}@{xr_peak_at:.2f}s")
                        else:
                            log(f"GATE open wait={time.time()-last_tap_ts:.2f}s ratio={cr_:.4f} diff={d_:.2f}")
                else:
                    time.sleep(TICK)
                    continue
            res = answer_once(engine, d, keys, img, syn)
            if res:
                cands, key = res
                rep = key == last_key and last_tap_sig is not None
                if rep and time.time() - last_tap_ts < RECHECK_MIN and not early:
                    time.sleep(TICK)
                    continue
                if rep:
                    attempt += 1
                else:
                    if attempt > 0 and last_tap is not None:
                        learn_word(d, keys, syn, last_tap[0], last_tap[1])
                    if trace:
                        log("TRACE " + json.dumps(trace))
                    last_key = key
                    attempt = 0
                opt = cands[attempt % len(cands)]
                tx, ty = opt[0], opt[1]
                adb("shell", f"input -d {INPUT_DISPLAY_ID} tap {tx} {ty}")
                log(f"TAPPED ({tx},{ty}) {opt[6]}  attempt#{attempt} @{key}")
                last_tap = (key, opt[6])
                last_tap_ts = time.time()
                last_tap_sig = sg
                last_full_sig = fs
                xr_seen = False
                xr_stable_at = 0.0
                xr_peak = 0.0
                xr_peak_at = 0.0
                gate_logged = False
                trace = []
                trace_done = False
            else:
                items = ocr_main(engine, img)
                btn, kind, lab = find_button(items)
                if btn is not None:
                    if kind == "RESTART" and not loop_pk:
                        log(f"模式一：单局结束({btn[6]})，停止不再重开")
                        grabber.stop()
                        return
                    adb("shell", f"input -d {INPUT_DISPLAY_ID} tap {btn[0]} {btn[1]}")
                    log(f"{kind} ({btn[0]},{btn[1]}) {btn[6]}")
                    time.sleep(BTN_WAIT)
                    last_key = None
                    attempt = 0
                    last_tap = None
                    last_tap_sig = None
                    last_full_sig = None
                    xr_seen = False
                    xr_stable_at = 0.0
                    xr_peak = 0.0
                    xr_peak_at = 0.0
                    gate_logged = False
                    trace = []
                    trace_done = False
                else:
                    log("[wait]")
            time.sleep(TICK)
        except KeyboardInterrupt:
            grabber.stop()
            break
        except Exception as e:
            print("ERR", e)
            time.sleep(1)

if __name__ == "__main__":
    argv = sys.argv[1:]
    if "--pkg" in argv:
        i = argv.index("--pkg")
        if i + 1 < len(argv):
            PKG = argv[i + 1].strip()
    for _ in range(15):
        if recheck_device():
            break
        print("[adb] no device, retrying...")
        time.sleep(2)
    if DEVICE is None:
        print("[adb] FATAL: 未找到 ADB 设备，请确认模拟器已启动")
        sys.exit(1)
    mode = argv[0] if argv else ""
    device = "gpu"
    if "--cpu" in argv or "-cpu" in argv:
        device = "cpu"
    if "--gpu" in argv or "-gpu" in argv:
        device = "gpu"
    if device == "gpu":
        try:
            import onnxruntime as _ort
            _probe = _ort.InferenceSession(
                os.path.join(os.path.dirname(_ort.__file__), "datasets", "mul_1.onnx"),
                providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
            if "CUDAExecutionProvider" not in _probe.get_providers():
                device = "cpu"
        except Exception:
            device = "cpu"
        if device == "cpu":
            print("[ocr] GPU unavailable, fallback to CPU inference")
    print("[ocr]", device, "inference")
    set_ocr_device(device)
    engine = RapidOCR()
    d = load_dict()
    keys = set(d)
    syn = load_syn()
    if mode == "once":
        img = grab_img()
        answer_once(engine, d, keys, img, syn)
    elif mode in ("single", "auto", "loop"):
        run_pk(engine, d, keys, syn, loop_pk=(mode != "single"))
    elif mode in ("1", "2"):
        run_pk(engine, d, keys, syn, loop_pk=(mode != "1"))
    elif not mode:
        print("===== 小猿口算单词PK 机器人 =====")
        print("模式一：普通答题（答完一局自动收奖励回主页，不循环PK）")
        print("模式二：循环PK（无限对战，胜/负自动重开）")
        sel = input("请选择 [1/2]，回车默认 2 > ").strip() or "2"
        run_pk(engine, d, keys, syn, loop_pk=(sel != "1"))
    else:
        print("用法: auto_pk.py [once|single|auto|loop|1|2]，或直接运行进入菜单")