"""
屏幕人物 AI 识别跟随工具 — 带 GUI 菜单

操作：
  - 点击「开启AI跟随」→ 启用功能
  - 按住鼠标左键 → 自动检测人物并跟随
  - 松开鼠标左键 → 取消跟随
"""

import sys
import os
import time
import threading
import math
import json
import base64
import hashlib
import socket
import urllib.request
import urllib.error
import uuid as _uuid
import psutil
import ctypes
from ctypes import wintypes
from collections import deque
from dataclasses import dataclass
from typing import Dict, Optional, Tuple, List

import cv2
import numpy as np
from PIL import ImageGrab
import pynput.mouse
import pynput.keyboard
import tkinter as tk
from tkinter import ttk
import mediapipe as mp


# ============================================================
#  卡密激活系统
# ============================================================

_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_KEYS_DAT = os.path.join(_BASE_DIR, "_keys.dat")
_ACTIVATION_DAT = os.path.join(_BASE_DIR, "_activation.dat")
_OBFUSCATE_KEY = b"CN_TRACKER_2026_SECRET_SALT_#@!"


def _xor_obfuscate(data: bytes) -> bytes:
    key = _OBFUSCATE_KEY
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))


def _get_machine_id() -> str:
    """获取本机唯一标识 (基于C盘卷序列号+主机名，重装系统前永久不变)"""
    try:
        host = socket.gethostname()
    except Exception:
        host = "unknown"
    try:
        # C 盘卷序列号 — 比 MAC 地址更稳定，不会因网卡变化而改变
        import win32api
        serial = win32api.GetVolumeInformation("C:\\")[1]
    except Exception:
        try:
            serial = _uuid.getnode()
        except Exception:
            serial = 0
    raw = f"{host}:{serial}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]


def load_valid_keys() -> list:
    """从 _keys.dat 加载有效卡密列表"""
    if not os.path.exists(_KEYS_DAT):
        return []
    try:
        with open(_KEYS_DAT, "rb") as f:
            raw = base64.b64decode(f.read())
        decrypted = _xor_obfuscate(raw)
        return json.loads(decrypted.decode("utf-8"))
    except Exception:
        return []


def verify_key(code: str) -> dict:
    """
    验证卡密是否有效
    返回: {"valid": bool, "key_info": dict, "reason": str}
    """
    keys = load_valid_keys()
    code = code.strip().upper()

    for k in keys:
        if k["code"] == code:
            if k.get("used", False):
                # 已使用但同机器可以继续用
                mid = _get_machine_id()
                if k.get("machine_id") == mid:
                    expire_ts = k.get("expire_ts", 0)
                    if expire_ts > 0 and time.time() < expire_ts:
                        return {"valid": True, "key_info": k, "reason": "重新激活(本机)"}
                    else:
                        return {"valid": False, "key_info": k, "reason": "卡密已过期"}
                return {"valid": False, "key_info": k, "reason": "此卡密已在其他机器激活"}
            else:
                # 未使用 → 激活后才计时，此处始终有效
                return {"valid": True, "key_info": k, "reason": "激活成功"}

    return {"valid": False, "key_info": None, "reason": "无效卡密"}


def activate_key(code: str) -> tuple:
    """激活卡密：优先在线激活，失败则降级到本地验证
    返回: (success: bool, reason: str)"""
    mid = _get_machine_id()

    # 1) 尝试在线激活
    online_ok, online_data = activate_key_online(code, mid)
    if online_ok:
        _save_activation_online(code, online_data)
        return (True, "激活成功")

    # 记录在线失败原因（而不是用本地"无效卡密"覆盖）
    if isinstance(online_data, dict):
        online_reason = online_data.get("error", "云端连接失败")
    else:
        online_reason = str(online_data) if online_data else "云端连接失败"

    # 2) 在线不通 → 本地离线激活
    result = verify_key(code)
    if result["valid"]:
        k = result["key_info"]
        now = time.time()
        duration_days = k.get("duration_days", 30)
        expire_ts = now + duration_days * 86400

        # 更新 _keys.dat 记录
        keys = load_valid_keys()
        for entry in keys:
            if entry["code"] == code:
                entry["used"] = True
                entry["used_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
                entry["machine_id"] = mid
                entry["expire_ts"] = expire_ts
                entry["expire_at"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(expire_ts))
        _save_keys(keys)

        # 本地激活记录
        local = {
            "code": code,
            "machine_id": mid,
            "activated_at": now,
            "expire_ts": expire_ts,
            "last_check": now,
        }
        _save_activation(local)
        return (True, "激活成功(离线)")

    # 3) 在线和本地都失败 → 显示在线失败原因（更准确）
    return (False, online_reason)


def _save_keys(keys: list):
    """保存卡密列表"""
    data = json.dumps(keys, ensure_ascii=False).encode("utf-8")
    encrypted = _xor_obfuscate(data)
    with open(_KEYS_DAT, "wb") as f:
        f.write(base64.b64encode(encrypted))


def _save_activation(data: dict):
    """保存本地激活状态"""
    raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
    encrypted = _xor_obfuscate(raw)
    with open(_ACTIVATION_DAT, "wb") as f:
        f.write(base64.b64encode(encrypted))


def check_local_activation() -> dict:
    """
    检查本地是否已激活
    返回 {"activated": bool, "info": str, "expire_ts": int}
    """
    if not os.path.exists(_ACTIVATION_DAT):
        return {"activated": False, "info": "未激活", "expire_ts": 0}

    try:
        with open(_ACTIVATION_DAT, "rb") as f:
            raw = base64.b64decode(f.read())
        decrypted = _xor_obfuscate(raw)
        data = json.loads(decrypted.decode("utf-8"))
    except Exception:
        return {"activated": False, "info": "激活数据损坏", "expire_ts": 0}

    mid = _get_machine_id()
    if data.get("machine_id") != mid:
        return {"activated": False, "info": "机器不匹配", "expire_ts": 0}

    expire_ts = data.get("expire_ts", 0)
    if time.time() > expire_ts:
        return {"activated": False, "info": "卡密已过期", "expire_ts": expire_ts}

    remain = int((expire_ts - time.time()) / 86400)
    code = data.get("code", "****")
    return {
        "activated": True,
        "info": f"已激活 ({code[:4]}-****, 剩余 {remain} 天)",
        "expire_ts": expire_ts,
    }


# ============================================================
#  在线验证 & 激活（优先在线，离线 fallback 到本地）
# ============================================================

HTTP_TIMEOUT = 10  # 秒


def _http_post(endpoint: str, payload: dict) -> dict:
    """发送 POST 到 API，返回解析后的 dict"""
    try:
        url = f"{API_BASE}/{endpoint}"
        data = json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(url, data=data,
            headers={
                "Content-Type": "application/json",
                "User-Agent": "AI-Screen-Tracker/1.1.0",
                "Accept": "application/json",
            })
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        try:
            return json.loads(e.read().decode("utf-8"))
        except Exception:
            return {"ok": False, "error": f"服务器错误 ({e.code})"}
    except Exception as e:
        return {"ok": False, "error": f"网络不通: {str(e)[:100]}"}


def verify_key_online(code: str) -> tuple:
    """在线验证卡密 (ok, data_or_error)"""
    result = _http_post("verify", {"code": code})
    if result.get("ok"):
        return True, result.get("data", {})
    return False, result.get("error", "验证失败")


def activate_key_online(code: str, machine_id: str) -> tuple:
    """在线激活卡密并绑定机器 (ok, data_or_error)"""
    result = _http_post("activate", {"code": code, "machine_id": machine_id})
    if result.get("ok"):
        return True, result.get("data", {})
    return False, result.get("error", "激活失败")


def _save_activation_online(code: str, data: dict):
    """在线激活成功后，把激活信息同时保存到本地（离线时可用）"""
    try:
        mid = _get_machine_id()
        save_data = {
            "code": code,
            "machine_id": mid,
            "expire_ts": data.get("expire_ts", int(time.time() + 365 * 86400)),
            "duration_days": data.get("duration_days", 30),
            "activated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
            "source": "online",
        }
        encrypted = _xor_obfuscate(json.dumps(save_data, ensure_ascii=False).encode("utf-8"))
        with open(_ACTIVATION_DAT, "wb") as f:
            f.write(base64.b64encode(encrypted))
    except Exception as e:
        log(f"[!] 保存本地激活记录失败: {e}")


def online_reactivate(code: str) -> bool:
    """在线重新验证已激活的卡密（用于续期检测）"""
    try:
        result = _http_post("verify", {"code": code})
        if result.get("ok"):
            data = result.get("data", {})
            if data.get("used") and data.get("machine_id") == _get_machine_id():
                if time.time() < data.get("expire_ts", 0):
                    _save_activation_online(code, data)
                    return True
        return False
    except Exception:
        return False


# ============================================================
#  自动更新检测
# ============================================================

__version__ = "1.0.0"
# 更新检测地址
UPDATE_URL = "https://ai-screen-tracker.pages.dev/version.json"
# 在线验证 API
API_BASE = "https://ai-screen-tracker.pages.dev/api"

_update_available = threading.Event()
_update_info: dict = {}


def check_for_updates() -> dict:
    """
    访问 UPDATE_URL/version.json 检查是否有新版本
    返回 {"has_update": bool, "latest": str, "url": str, "info": str}
    如果无法连接则返回 has_update=False
    """
    global _update_info
    if not UPDATE_URL or "你的网站" in UPDATE_URL:
        return {"has_update": False, "latest": __version__, "url": "", "info": ""}

    try:
        import urllib.request
        req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "AITracker/1.0"})
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode("utf-8"))
    except Exception:
        return {"has_update": False, "latest": __version__, "url": "", "info": ""}

    latest = data.get("version", __version__)
    url = data.get("url", "")
    info = data.get("info", "")

    if latest != __version__:
        _update_available.set()
        _update_info = {"has_update": True, "latest": latest, "url": url, "info": info}
        return _update_info

    return {"has_update": False, "latest": latest, "url": url, "info": info}


def get_update_info() -> dict:
    """获取缓存的更新信息（供 GUI 调用）"""
    if _update_available.is_set():
        return _update_info
    return {"has_update": False, "latest": __version__, "url": "", "info": ""}


# ============================================================
#  配置
# ============================================================

@dataclass
class Config:
    scan_region: int = 800             # 搜索半径，越大越能跟上快速移动
    follow_smooth: float = 0.25
    scan_interval: float = 0.04
    person_min_size: int = 60
    person_max_size: int = 500
    detection_interval: int = 2        # 重检测频率 (更低=更快跟上移动)
    match_threshold: float = 0.50
    capture_radius: int = 200          # 点击必须在人物中心此范围内才捕捉


CONFIG = Config()


# ============================================================
#  人体检测器
# ============================================================

class PersonDetector:
    def __init__(self):
        self.hog = cv2.HOGDescriptor()
        self.hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())

    def detect(self, image_bgr) -> List[Tuple[int, int, int, int]]:
        h, w = image_bgr.shape[:2]
        scale = 1.0
        if max(h, w) > 800:
            scale = 800.0 / max(h, w)
            new_w, new_h = int(w * scale), int(h * scale)
            image_bgr = cv2.resize(image_bgr, (new_w, new_h))

        rects, weights = self.hog.detectMultiScale(
            image_bgr, winStride=(4, 4), padding=(8, 8),
            scale=1.05, hitThreshold=0.3,
        )

        if scale != 1.0:
            rects = [(int(r[0] / scale), int(r[1] / scale),
                       int(r[2] / scale), int(r[3] / scale)) for r in rects]

        result = []
        for (x, y, w_box, h_box) in rects:
            if CONFIG.person_min_size <= w_box <= CONFIG.person_max_size and \
               CONFIG.person_min_size <= h_box <= CONFIG.person_max_size:
                result.append((x, y, w_box, h_box))
        return result


# ============================================================
#  状态管理
# ============================================================

@dataclass
class TrackerState:
    target_template: Optional[np.ndarray] = None
    target_bbox: Optional[Tuple[int, int, int, int]] = None
    target_center: Optional[Tuple[int, int]] = None
    ai_enabled: bool = False          # 是否开启了 AI 跟随
    is_tracking: bool = False         # 当前是否在跟随
    is_running: bool = True
    fps: float = 0.0
    status: str = "AI跟随未开启"
    confidence: float = 0.0
    lock_mode: str = "full"            # "head" | "torso" | "full"
    # 运动预测
    last_center: Optional[Tuple[int, int]] = None
    velocity: Tuple[float, float] = (0.0, 0.0)
    # 骨骼检测
    pose_landmarks: Optional[object] = None
    pose_bbox: Optional[Tuple[int, int, int, int]] = None


state = TrackerState()
state_lock = threading.Lock()

mouse_pos = (0, 0)
mouse_lock = threading.Lock()

frame_times = deque(maxlen=30)
detector = PersonDetector()


# ============================================================
#  核心逻辑
# ============================================================

def find_nearest_person(screen_region, click_rel_x, click_rel_y):
    persons = detector.detect(screen_region)
    if not persons:
        return None, None

    nearest, nearest_dist = None, float('inf')
    for (x, y, w, h) in persons:
        cx, cy = x + w // 2, y + h // 2
        dist = math.sqrt((cx - click_rel_x) ** 2 + (cy - click_rel_y) ** 2)
        if dist < nearest_dist:
            nearest_dist = dist
            nearest = (x, y, w, h)
    return nearest, nearest_dist


def extract_template(screen_region, bbox):
    x, y, w, h = bbox
    x = max(0, x); y = max(0, y)
    w = min(w, screen_region.shape[1] - x)
    h = min(h, screen_region.shape[0] - y)
    if w <= 0 or h <= 0:
        return np.zeros((1, 1, 3), dtype=np.uint8)
    return screen_region[y:y + h, x:x + w].copy()


def get_lock_sub_bbox(bbox, mode: str):
    """根据锁定模式从全身边界框裁剪子区域"""
    x, y, w, h = bbox
    if mode == "head":
        # 头部：上方 28%，宽度收窄
        head_h = int(h * 0.28)
        head_w = int(w * 0.55)
        return (x + (w - head_w) // 2, y, head_w, head_h)
    elif mode == "torso":
        # 锁甲：中上部 25% ~ 65% 区域
        torso_y = y + int(h * 0.20)
        torso_h = int(h * 0.45)
        torso_w = int(w * 0.75)
        return (x + (w - torso_w) // 2, torso_y, torso_w, torso_h)
    else:
        # 全身锁
        return bbox


# ============================================================
#  骨骼检测（MediaPipe Pose）
# ============================================================

class PoseSkeleton:
    """使用 MediaPipe 检测人体骨骼关键点"""

    # 骨骼连接定义（MediaPipe 33关键点）
    BONES = [
        # 躯干
        (11, 12), (11, 23), (12, 24), (23, 24),
        # 左臂
        (11, 13), (13, 15), (15, 17), (15, 19), (15, 21),
        # 右臂
        (12, 14), (14, 16), (16, 18), (16, 20), (16, 22),
        # 左腿
        (23, 25), (25, 27), (27, 29), (27, 31),
        # 右腿
        (24, 26), (26, 28), (28, 30), (28, 32),
        # 面部
        (0, 1), (0, 4), (1, 2), (4, 5), (2, 3), (5, 6),
        (3, 7), (6, 8), (9, 10),
    ]

    # 关键点颜色分组
    COLORS = {
        0:  '#ff4444',  # 鼻子 - 红色
        1:  '#ff8888', 2: '#ff8888', 3: '#ff8888',  # 左眼
        4:  '#ff8888', 5: '#ff8888', 6: '#ff8888',  # 右眼
        7:  '#ff8888', 8: '#ff8888', 9: '#ff8888', 10: '#ff8888',  # 耳朵/嘴
        11: '#4488ff', 12: '#4488ff',  # 肩膀 - 蓝色
        13: '#44ddff', 14: '#44ddff',  # 手肘
        15: '#ffaa44', 16: '#ffaa44',  # 手腕 - 橙色
        23: '#44ff88', 24: '#44ff88',  # 髋部 - 绿色
        25: '#44ff44', 26: '#44ff44',  # 膝盖
        27: '#aaff44', 28: '#aaff44',  # 脚踝
    }

    def __init__(self):
        # mediapipe 0.10.x 新版 API：用 PoseLandmarker（需要 .task 模型）
        from mediapipe.tasks import python as mp_python
        from mediapipe.tasks.python import vision as mp_vision

        if getattr(__import__('sys'), 'frozen', False):
            base_dir = sys._MEIPASS
        else:
            base_dir = os.path.dirname(os.path.abspath(__file__))
        model_path = os.path.join(base_dir, 'pose_landmarker_lite.task')

        base_options = mp_python.BaseOptions(model_asset_path=model_path)
        options = mp_vision.PoseLandmarkerOptions(
            base_options=base_options,
            running_mode=mp_vision.RunningMode.IMAGE,
            num_poses=1,
            min_pose_detection_confidence=0.5,
            min_pose_presence_confidence=0.5,
            min_tracking_confidence=0.5,
        )
        self.detector = mp_vision.PoseLandmarker.create_from_options(options)
        self._mp_image_module = mp

    def detect(self, image_bgr):
        """检测姿态关键点，返回 landmarks 列表（每个 landmark 有 x, y, z, visibility）或 None"""
        if image_bgr is None or image_bgr.size == 0:
            return None
        try:
            rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
            mp_img = self._mp_image_module.Image(
                image_format=self._mp_image_module.ImageFormat.SRGB, data=rgb)
            result = self.detector.detect(mp_img)
            if result.pose_landmarks and len(result.pose_landmarks) > 0:
                return result.pose_landmarks[0]  # 返回第一个人的 33 个 landmark
            return None
        except Exception:
            return None

    def release(self):
        try:
            self.detector.close()
        except Exception:
            pass


pose_detector = PoseSkeleton()


# ============================================================
#  骨骼叠加窗口（透明覆盖）
# ============================================================

class SkeletonOverlay:
    """透明 Toplevel 窗口，用于在游戏画面上叠加骨骼线"""

    def __init__(self, root):
        self.root = root
        self.win = None
        self.canvas = None
        self.visible = False
        self._pending = False  # 等待在主线程创建

    def _ensure_window(self):
        if self.win is not None:
            return
        self.win = tk.Toplevel(self.root)
        self.win.overrideredirect(True)
        self.win.attributes('-topmost', True)
        self.win.attributes('-transparentcolor', '#000001')
        self.win.configure(bg='#000001')
        self.win.withdraw()
        self.canvas = tk.Canvas(self.win, bg='#000001',
                                highlightthickness=0, bd=0)
        self.canvas.pack(fill='both', expand=True)

    def update(self, landmarks, person_bbox_screen):
        """在主线程调用，更新骨骼显示"""
        if landmarks is None or person_bbox_screen is None:
            self.hide()
            return

        self._ensure_window()

        x, y, w, h = person_bbox_screen
        pad = 30
        wx = max(0, x - pad)
        wy = max(0, y - pad)
        ww = w + pad * 2
        wh = h + pad * 2

        self.win.geometry(f'{ww}x{wh}+{wx}+{wy}')
        self.canvas.config(width=ww, height=wh)
        self.canvas.delete('all')

        # 提取关键点坐标（新 API 返回 List[NormalizedLandmark]）
        points = []
        for lm in landmarks:
            # 兼容旧 API 的 landmarks.landmark 格式
            if hasattr(lm, 'visibility'):
                visibility = lm.visibility
            else:
                visibility = 1.0
            px = int(lm.x * w) + pad
            py = int(lm.y * h) + pad
            points.append((px, py, visibility))

        if len(points) < 33:
            self.hide()
            return

        # 绘制骨骼连线
        for a, b in PoseSkeleton.BONES:
            if a < len(points) and b < len(points):
                ax, ay, av = points[a]
                bx, by, bv = points[b]
                if av > 0.5 and bv > 0.5:
                    # 根据关键点类型选颜色
                    color = '#00ff44'      # 默认绿色
                    alpha = min(av, bv)
                    # 躯干用亮青色
                    if a in (11, 12, 23, 24) and b in (11, 12, 23, 24):
                        color = '#00dddd'
                    elif a in (13, 14, 15, 16) or b in (13, 14, 15, 16):
                        color = '#44ddff'  # 手臂青色
                    elif a in (25, 26, 27, 28) or b in (25, 26, 27, 28):
                        color = '#44ff44'  # 腿部亮绿
                    self.canvas.create_line(ax, ay, bx, by,
                                           fill=color, width=2,
                                           capstyle='round')

        # 绘制关键点
        for i, (px, py, vis) in enumerate(points):
            if vis > 0.5:
                color = PoseSkeleton.COLORS.get(i, '#00ff44')
                r = 5 if i in (0, 11, 12, 15, 16, 23, 24) else 3
                self.canvas.create_oval(px - r, py - r, px + r, py + r,
                                       fill=color, outline='')

        self.visible = True
        self.win.deiconify()
        self.win.lift()

    def hide(self):
        if self.win is not None and self.visible:
            self.win.withdraw()
            self.visible = False

    def destroy(self):
        if self.win is not None:
            self.win.destroy()
            self.win = None
            self.canvas = None
            self.visible = False


def match_template_local(search_region, template_gray):
    if template_gray is None or template_gray.size == 0:
        return None, 0
    h_t, w_t = template_gray.shape
    h_s, w_s = search_region.shape[:2]
    if h_t > h_s or w_t > w_s:
        return None, 0

    search_gray = cv2.cvtColor(search_region, cv2.COLOR_BGR2GRAY)
    result = cv2.matchTemplate(search_gray, template_gray, cv2.TM_CCOEFF_NORMED)
    _, max_val, _, max_loc = cv2.minMaxLoc(result)

    if max_val < CONFIG.match_threshold:
        return None, max_val
    return (max_loc[0] + w_t // 2, max_loc[1] + h_t // 2), max_val


def tracking_loop():
    frame_count = 0
    update_template_counter = 0
    pose_tick = 0

    while state.is_running:
        t_start = time.time()

        with state_lock:
            tracking = state.is_tracking
            template = state.target_template.copy() if state.target_template is not None else None
            bbox = state.target_bbox
            vx, vy = state.velocity
            last_c = state.last_center

        if not tracking or template is None or bbox is None:
            time.sleep(0.1)
            continue

        tx, ty, tw, th = bbox
        center_x = tx + tw // 2
        center_y = ty + th // 2

        # 运动预测：用上一帧速度推算搜索中心
        predict_x = int(center_x + vx * 1.5)
        predict_y = int(center_y + vy * 1.5)

        half = CONFIG.scan_region // 2
        grab_bbox = (predict_x - half, predict_y - half, predict_x + half, predict_y + half)

        try:
            screen_img = ImageGrab.grab(bbox=grab_bbox, all_screens=True)
            screen_bgr = cv2.cvtColor(np.array(screen_img), cv2.COLOR_RGB2BGR)
            template_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY)
            match_pos, conf = match_template_local(screen_bgr, template_gray)

            if match_pos is not None:
                screen_x = match_pos[0] + (predict_x - half)
                screen_y = match_pos[1] + (predict_y - half)

                # 计算新速度
                new_vx, new_vy = 0.0, 0.0
                if last_c is not None:
                    new_vx = (screen_x - last_c[0]) * 0.7 + vx * 0.3
                    new_vy = (screen_y - last_c[1]) * 0.7 + vy * 0.3

                with state_lock:
                    state.target_bbox = (screen_x - tw // 2, screen_y - th // 2, tw, th)
                    state.target_center = (screen_x, screen_y)
                    state.last_center = (screen_x, screen_y)
                    state.velocity = (new_vx, new_vy)
                    state.confidence = conf
                    mode_label = {"head": "锁头", "torso": "锁甲", "full": "全身锁"}.get(state.lock_mode, state.lock_mode)
                    state.status = f"● {mode_label}跟随中 | 信心:{conf:.2f}"

                move_mouse_smooth(screen_x, screen_y)

                # 每隔一段时间更新模板，适应外观变化
                update_template_counter += 1
                if update_template_counter >= 15:
                    update_template_counter = 0
                    try:
                        new_tmpl = extract_template(screen_bgr,
                            (match_pos[0] - tw // 2, match_pos[1] - th // 2, tw, th))
                        if new_tmpl.size > 0:
                            with state_lock:
                                state.target_template = new_tmpl
                    except Exception:
                        pass

                # 骨骼刷新：每 8 帧检测一次
                pose_tick += 1
                if pose_tick >= 8:
                    pose_tick = 0
                    # 估算全人身边界框（相对于 screen_bgr）
                    person_w = tw * 4 if tw < 80 else max(tw, 120)
                    person_h = th * 5 if th < 80 else max(th, 250)
                    px = match_pos[0] - person_w // 2
                    py = match_pos[1] - person_h + th // 2
                    _refresh_pose_bg(
                        (px, py, person_w, person_h),
                        screen_bgr, (predict_x - half, predict_y - half))

            else:
                frame_count += 1
                # 模板匹配失败 → HOG 全检测找回目标
                if frame_count % CONFIG.detection_interval == 0:
                    big_half = CONFIG.scan_region
                    big_bbox = (predict_x - big_half, predict_y - big_half,
                                predict_x + big_half, predict_y + big_half)
                    big_img = ImageGrab.grab(bbox=big_bbox, all_screens=True)
                    big_bgr = cv2.cvtColor(np.array(big_img), cv2.COLOR_RGB2BGR)
                    persons = detector.detect(big_bgr)

                    if persons:
                        nearest, nearest_dist = None, float('inf')
                        for (x, y, w, h) in persons:
                            pcx, pcy = x + w // 2, y + h // 2
                            dist = math.sqrt((pcx - big_half) ** 2 + (pcy - big_half) ** 2)
                            if dist < nearest_dist:
                                nearest_dist = dist
                                nearest = (x, y, w, h)

                        if nearest:
                            x, y, w, h = nearest
                            # 应用锁定模式裁剪子区域
                            with state_lock:
                                lock_mode = state.lock_mode
                            sub = get_lock_sub_bbox(nearest, lock_mode)
                            new_template = extract_template(big_bgr, sub)
                            screen_bbox = (sub[0] + (predict_x - big_half),
                                           sub[1] + (predict_y - big_half), sub[2], sub[3])
                            screen_center = (screen_bbox[0] + sub[2] // 2, screen_bbox[1] + sub[3] // 2)

                            # 计算速度
                            new_vx, new_vy = 0.0, 0.0
                            if last_c is not None:
                                new_vx = (screen_center[0] - last_c[0]) * 0.7
                                new_vy = (screen_center[1] - last_c[1]) * 0.7

                            mode_label = {"head": "锁头", "torso": "锁甲", "full": "全身锁"}.get(lock_mode, lock_mode)
                            with state_lock:
                                state.target_template = new_template
                                state.target_bbox = screen_bbox
                                state.target_center = screen_center
                                state.last_center = screen_center
                                state.velocity = (new_vx, new_vy)
                                state.status = f"● {mode_label}重新锁定 | 信心:{state.confidence:.2f}"

                            move_mouse_smooth(screen_center[0], screen_center[1])

                with state_lock:
                    state.status = f"搜索中... (信心:{conf:.2f})"

        except Exception as e:
            with state_lock:
                state.status = f"错误: {str(e)[:40]}"

        elapsed = time.time() - t_start
        frame_times.append(elapsed)
        if frame_times:
            state.fps = 1.0 / (sum(frame_times) / len(frame_times))
        time.sleep(max(0, CONFIG.scan_interval - elapsed))


def move_mouse_smooth(target_x, target_y):
    with mouse_lock:
        cx, cy = mouse_pos
    s = CONFIG.follow_smooth
    nx, ny = int(cx + (target_x - cx) * (1 - s)), int(cy + (target_y - cy) * (1 - s))
    if math.sqrt((target_x - cx) ** 2 + (target_y - cy) ** 2) < 2:
        return
    try:
        pynput.mouse.Controller().position = (nx, ny)
    except Exception:
        pass


# ============================================================
#  输入处理
# ============================================================

def _detect_pose_bg(person_bbox, img_bgr, offset_x, offset_y):
    """在后台线程检测骨骼"""
    def _run():
        try:
            px, py, pw, ph = person_bbox
            person_img = img_bgr[py:py+ph, px:px+pw]
            if person_img.size == 0:
                return
            landmarks = pose_detector.detect(person_img)
            screen_bbox = (offset_x + px, offset_y + py, pw, ph)
            with state_lock:
                state.pose_landmarks = landmarks
                state.pose_bbox = screen_bbox
        except Exception:
            pass
    threading.Thread(target=_run, daemon=True).start()


def _refresh_pose_bg(person_bbox, screen_bgr, grab_offset):
    """在后台线程刷新骨骼（用于跟踪循环中的定期更新）"""
    def _run():
        try:
            px, py, pw, ph = person_bbox
            if px < 0: px = 0
            if py < 0: py = 0
            h_img, w_img = screen_bgr.shape[:2]
            x2 = min(px + pw, w_img)
            y2 = min(py + ph, h_img)
            if x2 <= px or y2 <= py:
                return
            person_img = screen_bgr[py:y2, px:x2]
            if person_img.size == 0:
                return
            landmarks = pose_detector.detect(person_img)
            actual_w = x2 - px
            actual_h = y2 - py
            screen_bbox = (grab_offset[0] + px, grab_offset[1] + py, actual_w, actual_h)
            with state_lock:
                state.pose_landmarks = landmarks
                state.pose_bbox = screen_bbox
        except Exception:
            pass
    threading.Thread(target=_run, daemon=True).start()


def on_click(x, y, button, pressed):
    if button != pynput.mouse.Button.left:
        return

    if not state.ai_enabled:
        return  # AI 未开启，忽略

    if pressed:
        half = CONFIG.scan_region // 2
        bbox = (x - half, y - half, x + half, y + half)

        try:
            img = ImageGrab.grab(bbox=bbox, all_screens=True)
            img_bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
            person, person_dist = find_nearest_person(img_bgr, half, half)

            if person is None:
                with state_lock:
                    state.is_tracking = False
                    state.status = "未检测到人物"
                return

            # 检查点击位置是否在人物周边
            if person_dist > CONFIG.capture_radius:
                with state_lock:
                    state.is_tracking = False
                    state.status = f"未在人物周边点击 (距离:{person_dist:.0f}px)"
                return

            px, py, pw, ph = person
            # 应用锁定模式：从全身边界框裁剪子区域
            with state_lock:
                lock_mode = state.lock_mode
            sub = get_lock_sub_bbox(person, lock_mode)
            template = extract_template(img_bgr, sub)
            # 子区域转屏幕坐标
            screen_bbox = (x - half + sub[0], y - half + sub[1], sub[2], sub[3])
            screen_center = (screen_bbox[0] + sub[2] // 2, screen_bbox[1] + sub[3] // 2)

            mode_label = {"head": "锁头", "torso": "锁甲", "full": "全身锁"}.get(lock_mode, lock_mode)

            with state_lock:
                state.target_template = template
                state.target_bbox = screen_bbox
                state.target_center = screen_center
                state.last_center = screen_center
                state.velocity = (0.0, 0.0)
                state.is_tracking = True
                state.status = f"● {mode_label}跟随中 | 信心:--"

            # 异步检测骨骼
            _detect_pose_bg(person, img_bgr, x - half, y - half)

        except Exception as e:
            with state_lock:
                state.status = f"检测出错: {e}"
    else:
        with state_lock:
            state.is_tracking = False
            state.target_template = None
            state.target_bbox = None
            state.target_center = None
            state.pose_landmarks = None
            state.pose_bbox = None
            state.status = "AI跟随已开启 (按住左键捕捉)"


def on_move(x, y):
    with mouse_lock:
        global mouse_pos
        mouse_pos = (x, y)


app_gui = None  # 由 main() 设置

def on_press(key):
    try:
        if key == pynput.keyboard.Key.f7:
            if app_gui:
                app_gui.root.after(0, app_gui.toggle_visibility)
        elif key == pynput.keyboard.Key.esc:
            with state_lock:
                state.is_running = False
            return False
    except Exception:
        pass


# ============================================================
#  激活窗口 (启动时先显示)
# ============================================================

class ActivationWindow:
    """卡密激活窗口 — 输入卡密才能进入主程序"""

    def __init__(self, on_success_callback):
        self.on_success = on_success_callback
        self.win = tk.Tk()
        self.win.title("卡密激活")
        self.win.geometry("340x280+{}+{}".format(
            self.win.winfo_screenwidth() // 2 - 170,
            self.win.winfo_screenheight() // 2 - 140,
        ))
        self.win.resizable(False, False)
        self.win.configure(bg="#0f0f1a")
        self.win.attributes("-topmost", True)

        # 图标/标题
        title_frame = tk.Frame(self.win, bg="#0f0f1a")
        title_frame.pack(pady=(24, 8))

        tk.Label(
            title_frame, text="🔐",
            font=("Segoe UI", 28),
            bg="#0f0f1a", fg="#c0caf5"
        ).pack()

        tk.Label(
            title_frame, text="请输入卡密激活",
            font=("SimHei", 13, "bold"),
            bg="#0f0f1a", fg="#ffffff"
        ).pack(pady=(4, 0))

        # 输入框
        input_frame = tk.Frame(self.win, bg="#0f0f1a")
        input_frame.pack(pady=(8, 4))

        self.entry = tk.Entry(
            input_frame,
            font=("Consolas", 14, "bold"),
            fg="#c0caf5", bg="#12121f",
            insertbackground="#c0caf5",
            relief="flat", bd=0,
            justify="center", width=22,
            highlightthickness=1,
            highlightbackground="#1f1f33",
            highlightcolor="#3a3a58",
        )
        self.entry.pack(ipady=6)
        self.entry.bind("<Return>", lambda e: self._activate())
        self.entry.focus_set()

        # 格式提示
        tk.Label(
            input_frame, text="格式: XXXX-XXXX-XXXX",
            font=("SimHei", 8), fg="#585b70", bg="#0f0f1a"
        ).pack(pady=(4, 0))

        # 激活按钮
        self.btn = tk.Button(
            self.win,
            text="激  活",
            font=("SimHei", 12, "bold"),
            fg="#c0caf5", bg="#1e2a4a",
            activebackground="#26365a", activeforeground="#c0caf5",
            relief="flat", bd=0, padx=40, pady=8,
            cursor="hand2",
            command=self._activate,
        )
        self.btn.pack(pady=(14, 6))

        # 消息提示
        self.msg_label = tk.Label(
            self.win, text="",
            font=("SimHei", 9),
            fg="#f38ba8", bg="#0f0f1a",
            wraplength=300,
        )
        self.msg_label.pack(pady=(2, 0))

    def _activate(self):
        code = self.entry.get().strip().upper()
        if not code:
            self._show_msg("请输入卡密", "#f38ba8")
            return

        if len(code) < 10:
            self._show_msg("卡密格式不正确", "#f38ba8")
            return

        self.btn.config(state="disabled", text="激活中...")
        self._show_msg("正在连接云端验证...", "#7aa2f7")
        self.win.update()

        # 在线激活跑后台线程，防止界面卡死
        def do_activate():
            success, reason = activate_key(code)
            if success:
                self.win.after(0, lambda: self._on_success())
            else:
                self.win.after(0, lambda: self._on_fail(reason))

        threading.Thread(target=do_activate, daemon=True).start()

    def _on_success(self):
        self._show_msg("激活成功！正在进入...", "#40f040")
        self.win.after(800, self._close_and_go)

    def _on_fail(self, reason):
        self._show_msg(reason, "#f38ba8")
        self.btn.config(state="normal", text="激  活")

    def _show_msg(self, text, color):
        self.msg_label.config(text=text, fg=color)

    def _close_and_go(self):
        self.win.destroy()
        self.on_success()

    def run(self):
        self.win.mainloop()


# ============================================================
#  GUI 界面 (tkinter)
# ============================================================

class AppGUI:
    def __init__(self):
        self.root = tk.Tk()
        self.root.overrideredirect(True)
        self.root.geometry("290x380+{}+{}".format(
            self.root.winfo_screenwidth() // 2 - 145,
            self.root.winfo_screenheight() // 2 - 190
        ))
        self.root.resizable(False, False)
        self.root.attributes("-topmost", True)
        self.root.attributes("-alpha", 0.92)

        self.root.configure(bg="#0f0f1a")

        self._drag_x = 0
        self._drag_y = 0
        self._hover_in = False

        # 标题栏
        self.title_bar = tk.Frame(self.root, bg="#12121f", height=30)
        self.title_bar.pack(fill="x")
        self.title_bar.bind("<Button-1>", self._drag_start)
        self.title_bar.bind("<B1-Motion>", self._drag_move)

        # 标题图标 + 文字
        self.title_lbl = tk.Label(
            self.title_bar,
            text="  内部AI捕捉程序",
            font=("SimHei", 11, "bold"),
            fg="#ffffff", bg="#12121f",
            anchor="w", padx=8,
            cursor="fleur"
        )
        self.title_lbl.pack(side="left", fill="y")
        self.title_lbl.bind("<Button-1>", self._drag_start)
        self.title_lbl.bind("<B1-Motion>", self._drag_move)

        # 右侧移动图标 (拖拽提示)
        self.move_icon = tk.Label(
            self.title_bar,
            text="⋮⋮",
            font=("Segoe UI", 11, "bold"),
            fg="#585b70", bg="#12121f",
            padx=10, cursor="fleur"
        )
        self.move_icon.pack(side="right")
        self.move_icon.bind("<Button-1>", self._drag_start)
        self.move_icon.bind("<B1-Motion>", self._drag_move)

        # ========== 分隔线 ==========
        self.sep = tk.Frame(self.root, bg="#1f1f33", height=1)
        self.sep.pack(fill="x")

        # ========== 内容区 ==========
        self.content = tk.Frame(self.root, bg="#0f0f1a")
        self.content.pack(fill="both", expand=True, padx=10, pady=(6, 8))

        # --- 状态指示灯 + 文本 ---
        self.status_row = tk.Frame(self.content, bg="#0f0f1a")
        self.status_row.pack(fill="x", pady=(4, 6))

        self.dot = tk.Canvas(self.status_row, width=8, height=8, bg="#0f0f1a",
                              bd=0, highlightthickness=0)
        self._dot_id = self.dot.create_oval(0, 0, 8, 8, fill="#45475a", outline="")
        self.dot.pack(side="left", padx=(2, 5))

        self.status_label = tk.Label(
            self.status_row,
            text="AI跟随未开启",
            font=("SimHei", 10),
            fg="#bac2de", bg="#0f0f1a",
            anchor="w"
        )
        self.status_label.pack(side="left")

        # --- 锁定模式选择 ---
        self.lock_section = tk.Frame(self.content, bg="#0f0f1a")
        self.lock_section.pack(fill="x", pady=(2, 2))

        tk.Label(self.lock_section, text="锁定模式",
                 font=("SimHei", 9), fg="#7f849c", bg="#0f0f1a").pack(side="left", padx=(0, 10))

        # 胶囊分段控件容器
        self.lock_pill = tk.Frame(self.lock_section, bg="#12121f", bd=0)
        self.lock_pill.pack(side="left")

        self._lock_btn_widgets = {}
        modes_pill = [
            ("🧠 锁头", "head"),
            ("🛡 锁甲", "torso"),
            ("🧍 全身", "full"),
        ]
        for text, mode_val in modes_pill:
            btn = tk.Button(
                self.lock_pill, text=text,
                font=("SimHei", 8, "bold"),
                fg="#585b70", bg="#12121f",
                activebackground="#1f1f33", activeforeground="#c0caf5",
                relief="flat", bd=0, padx=7, pady=4,
                cursor="hand2",
                command=lambda m=mode_val: self.set_lock_mode(m)
            )
            btn.pack(side="left", padx=0, pady=1)
            self._lock_btn_widgets[mode_val] = btn

        # 默认选中全身锁
        self._highlight_lock("full")

        # --- 开关按钮 (圆角卡片风格) ---
        self.btn_outer = tk.Frame(self.content, bg="#1f1f33")
        self.btn_outer.pack(pady=(8, 6), ipadx=2, ipady=2)

        self.ai_btn = tk.Button(
            self.btn_outer,
            text="🔘  开启AI跟随",
            font=("SimHei", 11, "bold"),
            fg="#c0caf5", bg="#2a2a40",
            activebackground="#3a3a58", activeforeground="#c0caf5",
            relief="flat", bd=0, padx=16, pady=7,
            cursor="hand2", width=16,
            command=self.toggle_ai
        )
        self.ai_btn.pack(padx=1, pady=1)

        # --- 操作提示 ---
        self.tip = tk.Label(
            self.content,
            text="按住左键捕捉  ·  松手取消  ·  F7 关闭/呼出",
            font=("SimHei", 9),
            fg="#f38ba8", bg="#0f0f1a"
        )
        self.tip.pack(pady=(10, 2))

        # --- 运行状态指示灯 (底部居中) ---
        self.run_bar = tk.Frame(self.content, bg="#0f0f1a")
        self.run_bar.pack(fill="x", pady=(2, 4))

        self.run_inner = tk.Frame(self.run_bar, bg="#0f0f1a")
        self.run_inner.pack(expand=True)

        self.run_dot = tk.Canvas(self.run_inner, width=7, height=7,
                                  bg="#0f0f1a", bd=0, highlightthickness=0)
        self._run_dot_id = self.run_dot.create_oval(0, 0, 7, 7, fill="#40f040", outline="")
        self.run_dot.pack(side="left", padx=(0, 4))

        self.run_label = tk.Label(
            self.run_inner, text="正常运行中",
            font=("SimHei", 9), fg="#a6adc8", bg="#0f0f1a"
        )
        self.run_label.pack(side="left")

        # 启动闪烁动画
        self._blink_state = True
        self._blink_running()

        # --- 运行中的程序列表 ---
        self.proc_sep = tk.Frame(self.content, bg="#1f1f33", height=1)
        self.proc_sep.pack(fill="x", pady=(2, 4))

        self.proc_title = tk.Label(
            self.content, text="正在运行的程序",
            font=("SimHei", 7), fg="#585b70", bg="#0f0f1a"
        )
        self.proc_title.pack(anchor="w", padx=(2, 0), pady=(0, 2))

        self.proc_list = tk.Text(
            self.content, height=4, width=36,
            font=("SimHei", 7),
            fg="#7f849c", bg="#0a0a14",
            bd=0, relief="flat", padx=6, pady=4,
            state="disabled", wrap="none"
        )
        self.proc_list.pack(fill="x", padx=4)

        # 启动进程扫描
        self._known_procs: Dict[str, float] = {}  # name->first_seen
        self._update_processes()
        self._proc_scan_timer()

        # --- 激活状态 (底部) ---
        local = check_local_activation()
        act_text = local.get("info", "未激活")
        act_color = "#40f040" if local["activated"] else "#f38ba8"
        self.act_label = tk.Label(
            self.content, text=act_text,
            font=("SimHei", 9), fg=act_color, bg="#0f0f1a"
        )
        self.act_label.pack(pady=(4, 0))

        # --- 免责声明 & 作者 (最底部) ---
        self.disc1 = tk.Label(
            self.content,
            text="本程序仅用于交流学习，仅在本地运行。",
            font=("SimHei", 9),
            fg="#585b70", bg="#0f0f1a"
        )
        self.disc1.pack(pady=(2, 0))

        self.disc2 = tk.Label(
            self.content,
            text="作者：画地为牢",
            font=("SimHei", 9),
            fg="#585b70", bg="#0f0f1a"
        )
        self.disc2.pack(pady=(0, 2))

        # --- 更新提示 (有更新时显示) ---
        self.update_label = tk.Label(
            self.content, text="",
            font=("SimHei", 9, "bold"),
            fg="#fabd2f", bg="#0f0f1a",
            cursor="hand2"
        )
        self.update_label.pack(pady=(0, 4))
        self.update_label.bind("<Button-1>", lambda e: self._on_update_click())

        # 延迟检查更新
        self.root.after(2000, self._check_update_ui)

        # 骨骼叠加窗口
        self.skeleton_overlay = SkeletonOverlay(self.root)

        self._update_status()

    def _highlight_lock(self, mode_val):
        """高亮选中的锁定模式按钮"""
        colors = {
            "head":   ("#c0caf5", "#2a1a3a"),  # 紫色
            "torso":  ("#c0caf5", "#1a2a3a"),  # 青蓝
            "full":   ("#c0caf5", "#1a1a3a"),  # 深蓝
        }
        for m, btn in self._lock_btn_widgets.items():
            if m == mode_val:
                fg, bg = colors.get(m, ("#c0caf5", "#2a2a40"))
                btn.config(fg=fg, bg=bg, activebackground=bg, state="disabled")
            else:
                btn.config(fg="#585b70", bg="#12121f",
                           activebackground="#1f1f33", state="normal")

    def set_lock_mode(self, mode_val):
        """切换锁定模式"""
        with state_lock:
            state.lock_mode = mode_val
            state.is_tracking = False
            # 生成状态消息
            names = {"head": "锁头", "torso": "锁甲", "full": "全身锁"}
            state.status = f"已切换{names.get(mode_val, mode_val)}模式 (按住左键捕捉)"
        self._highlight_lock(mode_val)

    def toggle_ai(self):
        with state_lock:
            state.ai_enabled = not state.ai_enabled
            if state.ai_enabled:
                state.status = "AI跟随已开启"
            else:
                state.is_tracking = False
                state.status = "AI跟随未开启"

    def _drag_start(self, event):
        self._drag_x = event.x
        self._drag_y = event.y

    def _drag_move(self, event):
        x = self.root.winfo_x() + event.x - self._drag_x
        y = self.root.winfo_y() + event.y - self._drag_y
        self.root.geometry(f"+{x}+{y}")

    def toggle_visibility(self):
        """F7 键：隐藏/呼出菜单"""
        if self.root.state() == "withdrawn":
            self.root.deiconify()
            self.root.lift()
        else:
            self.root.withdraw()

    def _blink_running(self):
        """底部绿色指示灯闪烁"""
        self._blink_state = not self._blink_state
        color = "#40f040" if self._blink_state else "#1a3a1a"
        try:
            self.run_dot.itemconfig(self._run_dot_id, fill=color)
        except Exception:
            return
        self.root.after(800, self._blink_running)

    # ── 游戏/娱乐程序过滤 ──────────────────────────────────
    # 游戏平台目录关键词（路径含这些的视为游戏）
    _GAME_PATHS = (
        "steamapps\\common",
        "steam\\steamapps",
        "epic games", "epicgames",
        "riot games",
        "ubisoft", "ubisoft game launcher",
        "origin games", "ea games",
        "battle.net",
        "gog galaxy", "gog games",
        "xboxgames", "microsoft games",
        "wegame", "腾讯游戏",
        "netease", "netease games",
    )

    # emulator / 模拟器 exe 名
    _EMULATOR_EXES = (
        "dnplayer", "ldplayer", "ldconsole",   # 雷电
        "nox", "noxvmhandle",                    # 夜神
        "mumuplayer", "nemu",                    # MuMu
        "bluestacks", "hd-player", "hd-adb",     # BlueStacks
        "memu", "memuconsole",                   # 逍遥
        "leidian",                               # 雷电变体
        "gameloop", "txgameloop",                # Gameloop
    )

    # 常见游戏 exe 名（无窗口标题时用）
    _KNOWN_GAME_EXES = (
        "gta5", "gtaiv", "rdr2", "csgo", "dota2", "pubg", "fortnite",
        "valorant", "apex", "cod", "modernwarfare", "blackops",
        "rainbowsix", "r6", "siege",
        "league of legends", "leagueclient", "leagueclientux",
        "fifa", "nba2k", "madden", "f120", "f122", "f123", "f124",
        "minecraft", "terraria", "stardew", "valheim",
        "genshinimpact", "yuanshen", "honkai", "starrail", "zenlesszonezero",
        "wutheringwaves", "naraka", "eldenring", "darksouls",
        "cyberpunk2077", "witcher3", "hogwarts",
        "deltaforce", "dfclient",                 # 三角洲行动
        "crossfire", "cf", "cfhd",
        "dnf", "dnf2",                             # DNF
        "lostark", "world of warcraft", "wow",
        "overwatch", "ow2",
        "pathofexile", "poe",
        "deadbydaylight", "dbd",
        "warframe", "destiny2",
        "monsterhunter", "mhw", "mhr",
        "residentevil", "re4", "re8",
        "finalfantasy", "ffxiv", "ff14", "ffxv",
        "assassinscreed", "acorigins", "acodyssey",
        "far cry", "farer", "farcry",
        "tombraider", "shadowofthetombraider",
        "battlefield", "bf2042", "bfv",
        "needforspeed", "nfs", "forzahorizon", "forza",
        "counterstrike", "cs2",
        "deathstranding",
        "sekiro", "nioh", "nioh2",
    )

    # 娱乐类程序 exe 名
    _ENTERTAINMENT_EXES = (
        "spotify", "netflix", "vlc", "potplayer", "kmplayer",
        "qqmusic", "qq音乐", "kugou", "kuwo",
        "musicplayer", "music", "video", "media",
    )

    @staticmethod
    def _is_game_or_entertainment(exe_path: str, exe_name: str) -> bool:
        """判断是否为游戏/娱乐程序"""
        lp = exe_path.lower()
        ln = exe_name.lower()

        # 路径在游戏平台目录
        for gp in AppGUI._GAME_PATHS:
            if gp in lp:
                return True

        # 模拟器
        for emu in AppGUI._EMULATOR_EXES:
            if emu == ln:
                return True

        # 已知游戏
        for game in AppGUI._KNOWN_GAME_EXES:
            if game == ln or game.replace(" ", "") == ln:
                return True

        # 娱乐类
        for ent in AppGUI._ENTERTAINMENT_EXES:
            if ent == ln:
                return True

        # 路径在用户桌面 / 可能是绿色游戏
        if "\\desktop\\" in lp or "\\下载\\" in lp or "\\downloads\\" in lp:
            if any(kw in ln for kw in ("game", "游戏", "launcher", "启动器")):
                return True

        return False

    def _get_user_processes(self):
        """通过 Windows API 枚举任务栏中的游戏/娱乐程序"""
        result: Dict[str, str] = {}
        user32 = ctypes.windll.user32
        kernel32 = ctypes.windll.kernel32

        entries = []  # [(exe_key, title, exe_path), ...]

        WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)

        def enum_callback(hwnd, lparam):
            if not user32.IsWindowVisible(hwnd):
                return True

            length = user32.GetWindowTextLengthW(hwnd)
            if length == 0:
                return True

            GWL_EXSTYLE = -20
            ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
            WS_EX_TOOLWINDOW = 0x00000080
            if ex_style & WS_EX_TOOLWINDOW:
                return True

            GW_OWNER = 4
            owner = user32.GetWindow(hwnd, GW_OWNER)
            if owner != 0:
                return True

            # 窗口标题 (用于显示)
            title_buf = ctypes.create_unicode_buffer(length + 1)
            user32.GetWindowTextW(hwnd, title_buf, length + 1)
            title = title_buf.value.strip()

            # 进程 ID → exe 路径
            pid = wintypes.DWORD()
            user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))

            PROCESS_QUERY_LIMITED_INFO = 0x1000
            h_process = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFO, False, pid.value)
            if h_process:
                exe_buf = ctypes.create_unicode_buffer(260)
                size = wintypes.DWORD(260)
                if kernel32.QueryFullProcessImageNameW(h_process, 0, exe_buf, ctypes.byref(size)):
                    exe_path = exe_buf.value
                    exe_key = exe_path.rsplit('\\', 1)[-1].lower()
                    if exe_key.endswith('.exe'):
                        exe_key = exe_key[:-4]

                    # 只保留游戏/娱乐程序
                    if self._is_game_or_entertainment(exe_path, exe_key):
                        entries.append((exe_key, title, exe_path))
                kernel32.CloseHandle(h_process)

            return True

        try:
            user32.EnumWindows(WNDENUMPROC(enum_callback), 0)
        except Exception:
            pass

        # 去重：同一 exe 显示窗口标题，优先显示中文短标题
        for exe_key, title, _ in entries:
            if exe_key in result:
                if len(title) < len(result[exe_key]) and len(result[exe_key]) > 30:
                    result[exe_key] = title
            else:
                result[exe_key] = title
        return result

    def _update_processes(self):
        """更新进程列表显示"""
        procs = self._get_user_processes()
        now = time.time()

        # 记录首次出现时间
        for key in procs:
            if key not in self._known_procs:
                self._known_procs[key] = now

        # 清理已退出的
        gone = [k for k in self._known_procs if k not in procs]
        for k in gone:
            del self._known_procs[k]

        # 显示列表 (按首次出现时间排序, 最多 6 条)
        sorted_items = sorted(self._known_procs.items(), key=lambda x: x[1], reverse=True)[:6]
        names = [procs.get(k, k) for k, _ in sorted_items]

        self.proc_list.config(state="normal")
        self.proc_list.delete("1.0", "end")
        if names:
            self.proc_list.insert("1.0", "\n".join(names))
        else:
            self.proc_list.insert("1.0", "( 暂无检测到用户程序 )")
        self.proc_list.config(state="disabled")

    def _proc_scan_timer(self):
        """每 3 秒扫描一次进程"""
        self._update_processes()
        self.root.after(3000, self._proc_scan_timer)

    def _check_update_ui(self):
        """后台查更新，有新版则显示提示"""
        info = get_update_info()
        if info.get("has_update"):
            new_ver = info.get("latest", "")
            desc = info.get("info", "")
            text = f"⬆ 新版本 {new_ver} 可用"
            if desc:
                text += f" — {desc}"
            self.update_label.config(text=text)
            self.update_label.pack(pady=(0, 4))  # 确保可见
        # 否则保持隐藏（空 text）

    def _on_update_click(self):
        """用户点击更新标签 → 打开下载链接"""
        import webbrowser
        info = get_update_info()
        url = info.get("url", "")
        if url:
            try:
                webbrowser.open(url)
            except Exception:
                pass

    def _update_status(self):
        """每 150ms 刷新 GUI 状态"""
        with state_lock:
            enabled = state.ai_enabled
            status = state.status
            fps = state.fps

        if enabled:
            self.ai_btn.config(
                text="⏸  关闭AI跟随",
                bg="#5a1a2a", fg="#f38ba8",
                activebackground="#6e1e32", activeforeground="#f38ba8"
            )
            self.dot.itemconfig(self._dot_id, fill="#38bdf8")
        else:
            self.ai_btn.config(
                text="🔘  开启AI跟随",
                bg="#1e2a4a", fg="#7aa2f7",
                activebackground="#26365a", activeforeground="#7aa2f7"
            )
            self.dot.itemconfig(self._dot_id, fill="#45475a")
            with state_lock:
                state.status = "AI跟随未开启"

        status_text = status
        if enabled and fps > 0:
            status_text = f"{status}  ·  {fps:.0f} FPS"

        self.status_label.config(text=status_text[:42])

        # 更新骨骼叠加
        with state_lock:
            landmarks = state.pose_landmarks
            pbbox = state.pose_bbox
            tracking = state.is_tracking
        if tracking and pbbox:
            self.skeleton_overlay.update(landmarks, pbbox)
        else:
            self.skeleton_overlay.hide()

        if state.is_running:
            self.root.after(150, self._update_status)
        else:
            self.root.destroy()

    def run(self):
        self.root.mainloop()


# ============================================================
#  入口
# ============================================================

def main():
    global app_gui

    # ── 后台检查更新 ──
    threading.Thread(target=check_for_updates, daemon=True).start()

    # ── 检查本地激活 ──
    local = check_local_activation()
    if not local["activated"]:
        # 必须先激活
        def on_activated():
            global app_gui
            # 激活成功后启动主程序
            threading.Thread(target=tracking_loop, daemon=True).start()
            ml = pynput.mouse.Listener(on_click=on_click, on_move=on_move)
            kl = pynput.keyboard.Listener(on_press=on_press)
            ml.start()
            kl.start()
            app_gui = AppGUI()
            app_gui.run()
            with state_lock:
                state.is_running = False
            ml.stop()
            kl.stop()

        ActivationWindow(on_activated).run()
        return

    # 已激活，直接进入
    # 跟踪线程
    threading.Thread(target=tracking_loop, daemon=True).start()

    # 输入监听
    ml = pynput.mouse.Listener(on_click=on_click, on_move=on_move)
    kl = pynput.keyboard.Listener(on_press=on_press)
    ml.start()
    kl.start()

    # GUI (主线程)
    app_gui = AppGUI()
    app_gui.run()

    # 退出清理
    with state_lock:
        state.is_running = False
    ml.stop()
    kl.stop()


if __name__ == "__main__":
    main()
