#!/usr/bin/env python3
"""TNG 全流程自动化：注册区号 + 登录 PIN/区号；两次冷启动，互不干扰。"""
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET

PKG = "my.com.tngdigital.ewallet"
SPLASH = f"{PKG}/.ui.SplashActivity"
ADB = ["adb"]

TNG_LOG = re.compile(
    r"TngRoot|F HWUI|GraphicBuffer|Runtime abort|signal 6|exited due to signal"
    r"|UserSearchCallingCode|UserLogin|UserRegistration|SecurityError"
    r"|Dialog\.show|skip loading|registration flow",
    re.I,
)
CRASH_LOG = re.compile(
    r"signal 6|Runtime aborting|F HWUI|F GraphicBuffer|gralloc-mapper is missing",
    re.I,
)


def adb(*args, timeout=60):
    r = subprocess.run(ADB + list(args), capture_output=True, text=True, timeout=timeout, errors="ignore")
    return r.returncode, (r.stdout or "") + (r.stderr or "")


def is_tng_foreground():
    _, out = adb("shell", "dumpsys", "activity", "activities")
    for line in out.splitlines():
        if "topResumedActivity=" in line:
            return PKG in line
    return False


def bring_tng_foreground():
    adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
    if is_tng_foreground():
        return
    adb("shell", "am", "start", "-n", SPLASH)
    time.sleep(2)


def dump_ui(path="/sdcard/tng_ui.xml", retries=2):
    adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
    for _ in range(retries):
        adb("shell", "uiautomator", "dump", path)
        code, xml = adb("shell", "cat", path)
        if code == 0 and xml.strip().startswith("<?xml"):
            try:
                return ET.fromstring(xml)
            except ET.ParseError:
                pass
        time.sleep(1)
    return None


def center(bounds_str):
    m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str or "")
    if not m:
        return None
    x1, y1, x2, y2 = map(int, m.groups())
    return (x1 + x2) // 2, (y1 + y2) // 2


def find_pin_login_row(root):
    candidates = []
    for node in root.iter("node"):
        t = node.get("text") or ""
        if "忘记" in t:
            continue
        pt = center(node.get("bounds"))
        if pt is None:
            continue
        # 登录方式页 PIN 行 y≈600–780
        if 600 <= pt[1] <= 780 and (
            "PIN" in t.upper() or "6位数" in t or "6位" in t or (len(t) >= 4 and "PIN" in t)
        ):
            candidates.append((pt[1], node))
    if not candidates:
        return None
    candidates.sort(key=lambda x: x[0])
    return candidates[0][1]


def wait_phone_page(timeout=20):
    return wait_for(lambda: is_phone_page(dump_ui()), timeout=timeout, interval=1.5, desc="phone page")


def find_nodes(root, **kwargs):
    out = []
    for node in root.iter("node"):
        text = (node.get("text") or "").strip()
        rid = node.get("resource-id") or ""
        cls = node.get("class") or ""
        clickable = node.get("clickable") == "true"
        ok = True
        if "text_contains" in kwargs and kwargs["text_contains"] not in text:
            ok = False
        if "text_excludes" in kwargs:
            for ex in kwargs["text_excludes"]:
                if ex in text:
                    ok = False
        if "rid_contains" in kwargs and kwargs["rid_contains"] not in rid:
            ok = False
        if "class_contains" in kwargs and kwargs["class_contains"] not in cls:
            ok = False
        if kwargs.get("clickable") and not clickable:
            ok = False
        if ok:
            out.append(node)
    return out


def tap_node(node, label=""):
    pt = center(node.get("bounds"))
    if not pt:
        print(f"  skip tap {label}: bad bounds")
        return False
    x, y = pt
    print(f"  tap {label!r} at {x},{y}")
    adb("shell", "input", "tap", str(x), str(y))
    return True


def find_country_control(root, min_y=0, max_y=9999):
    nodes = find_nodes(root, rid_contains="ll_country", clickable=True)
    for node in nodes:
        pt = center(node.get("bounds"))
        if pt and min_y <= pt[1] <= max_y:
            return node
    nodes = find_nodes(root, rid_contains="tv_left", clickable=True)
    for node in nodes:
        pt = center(node.get("bounds"))
        if pt and min_y <= pt[1] <= max_y:
            return node
    return None


def find_register_btn(root):
    for node in root.iter("node"):
        if node.get("clickable") != "true":
            continue
        t = node.get("text") or ""
        if ("注册" in t or "Register" in t.lower()) and "已经" not in t and "已注" not in t:
            return node
        pt = center(node.get("bounds"))
        if pt and 1050 < pt[1] < 1220 and 430 < pt[0] < 660:
            return node
    return None


def is_phone_page(root):
    return bool(find_nodes(root, rid_contains="userContinueBtn"))


def is_register_page(root):
    return bool(
        find_nodes(root, rid_contains="ftv_register_title")
        or find_nodes(root, rid_contains="userRegisterContinueBtn")
    )


def has_country_list(root):
    if root is None:
        return False
    for node in root.iter("node"):
        t = node.get("text") or ""
        if any(k in t for k in ("Malaysia", "Singapore", "Australia", "China", "+86", "+61")):
            return True
    return False


def pid():
    code, out = adb("shell", "pidof", PKG)
    return out.strip() if code == 0 and out.strip() else ""


def top_activity():
    _, out = adb("shell", "dumpsys", "activity", "activities")
    for line in out.splitlines():
        if PKG not in line:
            continue
        if "topResumedActivity=" in line:
            m = re.search(r"/([^/}\s]+)", line)
            return m.group(1) if m else "?"
    for line in out.splitlines():
        if "ResumedActivity:" in line and PKG in line:
            m = re.search(r"/([^/}\s]+)", line)
            if m:
                return m.group(1)
    return "?"


def wait_for(fn, timeout=35, interval=1.5, desc=""):
    deadline = time.time() + timeout
    while time.time() < deadline:
        if fn():
            return True
        time.sleep(interval)
    print(f"  timeout: {desc}")
    return False


def wait_ui_widgets(timeout=30):
    """等待 Compose 控件出现在 dump（Splash WebView 退场后）。"""
    def ready():
        root = dump_ui()
        if root is None:
            return False
        return bool(
            find_pin_login_row(root)
            or find_register_btn(root)
            or is_phone_page(root)
            or find_nodes(root, rid_contains="ftv_content")
            or find_nodes(root, rid_contains="ftv_register_title")
        )

    return wait_for(ready, timeout=timeout, interval=2, desc="login/register widgets")


def wait_login_method(timeout=40):
    def ready():
        if not pid():
            return False
        if not is_tng_foreground():
            bring_tng_foreground()
        return "UserLoginActivity" in top_activity()

    ok = wait_for(ready, timeout=timeout, interval=2, desc="UserLoginActivity ready")
    if ok:
        wait_ui_widgets(timeout=25)
    return ok


def wait_login_method_register(timeout=40):
    return wait_login_method(timeout=timeout)


def wait_country_list(timeout=30):
    def activity_has_country_list():
        act = top_activity()
        if "UserSearchCallingCode" in act:
            return True
        _, out = adb("shell", "dumpsys", "activity", "activities")
        return "UserSearchCallingCodeActivity" in out and PKG in out

    def ready():
        if activity_has_country_list():
            return True
        root = dump_ui()
        return has_country_list(root)

    return wait_for(ready, timeout=timeout, interval=1.5, desc="country list")


def cold_start(clear_log=False):
    if clear_log:
        adb("logcat", "-c")
    adb("shell", "am", "force-stop", PKG)
    time.sleep(3)
    adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
    adb("shell", "am", "start", "-W", "-n", SPLASH)
    ok = wait_for(
        lambda: bool(pid()) and "UserLoginActivity" in top_activity(),
        timeout=60,
        interval=2,
        desc="UserLoginActivity after cold start",
    )
    if not ok:
        return False
    bring_tng_foreground()
    time.sleep(3)
    return True


def cold_start_with_retry(clear_log=False, attempts=3):
    for i in range(attempts):
        if cold_start(clear_log and i == 0):
            return True
        print(f"  cold start retry {i + 1}/{attempts}")
        adb("shell", "am", "force-stop", PKG)
        time.sleep(5)
    return bool(pid()) and "UserLoginActivity" in top_activity()


def tap_pin_row(root):
    if root is None:
        print("  PIN fallback (no dump) 561,677")
        adb("shell", "input", "tap", "561", "677")
        return
    pin_text = find_pin_login_row(root)
    if pin_text is None:
        if find_nodes(root, rid_contains="ftv_content") or find_nodes(root, rid_contains="ftv_title"):
            print("  PIN layout fallback 561,677")
            adb("shell", "input", "tap", "561", "677")
            return
        print("  PIN fallback tap 540,677")
        adb("shell", "input", "tap", "540", "677")
        return
    pt = center(pin_text.get("bounds"))
    print(f"  tap PIN at {pt[0]},{pt[1]}")
    adb("shell", "input", "tap", str(pt[0]), str(pt[1]))


def collect_logs(n=3000):
    _, out = adb("shell", "logcat", "-d", "-t", str(n))
    return [ln for ln in out.splitlines() if TNG_LOG.search(ln)]


def collect_crashes(n=4000):
    _, out = adb("shell", "logcat", "-d", "-t", str(n))
    return [ln for ln in out.splitlines() if CRASH_LOG.search(ln) and "digital.ewallet" in ln]


def step(name, fn):
    print(f"\n=== {name} ===")
    bring_tng_foreground()
    ok = fn()
    print(f"  pid={pid() or 'DEAD'} activity={top_activity()}")
    return ok and bool(pid())


def wait_register_page(timeout=20):
    def ready():
        root = dump_ui()
        if root is None or not is_register_page(root):
            return False
        ctrl = find_country_control(root, min_y=680)
        if ctrl is None:
            return False
        pt = center(ctrl.get("bounds"))
        # 注册页 ll_country 中心 y 通常 > 680
        return pt is not None and pt[1] >= 680

    ok = wait_for(ready, timeout=timeout, interval=1.5, desc="register page settled")
    if ok:
        time.sleep(1)
    return ok


def flow_register():
    time.sleep(5)
    if not cold_start_with_retry():
        return False
    if not wait_login_method_register():
        print("  register entry not visible")
        return False
    root = dump_ui()
    reg = find_register_btn(root)
    if reg is None:
        print("  register fallback 540,1137")
        adb("shell", "input", "tap", "540", "1137")
    else:
        tap_node(reg, "register")
    if not wait_register_page():
        return False
    root2 = dump_ui()
    target = find_country_control(root2, min_y=680)
    if target is None:
        print("  no country control on register page")
        return False
    tap_node(target, "register country")
    if not wait_country_list(timeout=25):
        return False
    root3 = dump_ui()
    if root3 is not None:
        for node in root3.iter("node"):
            t = node.get("text") or ""
            if "Malaysia" in t or "Singapore" in t:
                print(f"  country UI: {t[:40]}")
                break
    logs = collect_logs(300)
    for ln in logs:
        if "skip loading" in ln or "UserSearchCallingCode" in ln:
            print(f"  log: {ln[:120]}")
            break
    return True


def flow_login():
    if not cold_start_with_retry(clear_log=True):
        return False
    if not wait_login_method():
        return False
    root = dump_ui()
    if is_phone_page(root):
        print("  already on phone page")
    else:
        for attempt in range(2):
            root = dump_ui()
            if root is None:
                time.sleep(2)
                continue
            if is_phone_page(root):
                print("  phone page ready")
                break
            tap_pin_row(root)
            if wait_phone_page(timeout=15):
                break
            print(f"  phone page retry {attempt + 1}/2")
            time.sleep(2)
        else:
            return False
    root2 = dump_ui()
    target = find_country_control(root2, min_y=450, max_y=650)
    if target is None:
        print("  no country control on phone page")
        return False
    tap_node(target, "login country")
    if not wait_country_list():
        return False
    root3 = dump_ui()
    if root3 is not None:
        for node in root3.iter("node"):
            t = node.get("text") or ""
            if "Malaysia" in t or "Singapore" in t:
                print(f"  country UI: {t[:40]}")
                break
    logs = collect_logs(300)
    for ln in logs:
        if "skip loading" in ln:
            print(f"  log: {ln[:120]}")
            break
    return "UserSearchCallingCode" in top_activity() or has_country_list(root3)


def main():
    adb("shell", "svc", "power", "stayon", "true")
    adb("logcat", "-c")
    adb("shell", "am", "force-stop", PKG)
    time.sleep(5)

    # 先登录后注册：首次冷启动最稳定
    results = [
        ("login_country", step("A. login PIN → country list", flow_login)),
        ("register_country", step("B. register → country list", flow_register)),
    ]

    crashes = collect_crashes()
    p = pid()

    print("\n=== SUMMARY ===")
    for name, ok in results:
        print(f"  {'PASS' if ok else 'FAIL'} {name}")
    print(f"  pid={p or 'DEAD'} activity={top_activity()}")
    print(f"  crashes={len(crashes)}")
    if crashes:
        for ln in crashes[-5:]:
            print("   ", ln[:150])

    print("\n=== TngRoot (last 20) ===")
    for ln in collect_logs(2000)[-20:]:
        if "TngRoot" in ln:
            print(ln[:180])

    ok = bool(p) and all(r[1] for r in results)
    if crashes and not ok:
        print("  (crash lines may include prior process; see pid)")
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())
