#!/usr/bin/env python3
"""
WoW 多功能控制器 GUI
集成：状态监控、巡逻打怪、自动打怪、任务跟随、巡逻点录制、多键控制
"""

import json
import os
import random
import sys
import time

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QLabel, QPushButton, QCheckBox, QSpinBox, QDoubleSpinBox, QScrollArea, QTextEdit,
    QMessageBox, QTabWidget, QGroupBox, QFormLayout, QLineEdit,
    QComboBox, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
)
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtGui import QPainter, QPen, QColor
import win32gui
import win32api
import win32con


def _config_base():
    """配置文件所在目录（支持打包 exe）。"""
    if getattr(sys, 'frozen', False):
        return os.path.dirname(sys.executable)
    return os.path.dirname(os.path.abspath(__file__))


def get_config_path(filename):
    """解析配置文件路径。"""
    base = _config_base()
    p = os.path.join(base, filename)
    if os.path.exists(p):
        return p
    if getattr(sys, 'frozen', False) and getattr(sys, '_MEIPASS', ''):
        p2 = os.path.join(sys._MEIPASS, filename)
        if os.path.exists(p2):
            return p2
    return p


CONFIG_FILE = "wow_multikey_qt.json"
RECORDER_DIR = "recorder"  # 录制与巡逻配置文件存放目录
COMBAT_LOOPS_DIR = "combat_loops"  # 攻击循环配置存放目录


def get_recorder_dir():
    """recorder 文件夹路径，不存在则创建"""
    path = os.path.join(_config_base(), RECORDER_DIR)
    os.makedirs(path, exist_ok=True)
    return path


def list_recorder_json():
    """列出 recorder 文件夹下所有 .json 文件，返回 [(显示名, 完整路径), ...]"""
    rec_dir = get_recorder_dir()
    result = []
    for name in sorted(os.listdir(rec_dir)):
        if name.lower().endswith('.json'):
            result.append((name, os.path.join(rec_dir, name)))
    return result


def get_combat_loops_dir():
    """combat_loops 文件夹路径，不存在则创建"""
    path = os.path.join(_config_base(), COMBAT_LOOPS_DIR)
    os.makedirs(path, exist_ok=True)
    return path


def list_combat_loop_files():
    """列出 combat_loops 下所有 .json，返回 [(显示名, 完整路径), ...]"""
    loop_dir = get_combat_loops_dir()
    result = []
    for name in sorted(os.listdir(loop_dir)):
        if name.lower().endswith('.json'):
            result.append((name, os.path.join(loop_dir, name)))
    return result


# ============ 巡逻点预览画布 ============


class WaypointPreviewCanvas(QWidget):
    """简单的 0~100 坐标系预览画布，按顺序连线显示路径；支持滚轮缩放、鼠标拖拽平移。"""

    ZOOM_MIN = 0.2
    ZOOM_MAX = 10.0
    ZOOM_STEP = 1.15

    def __init__(self, parent=None):
        super().__init__(parent)
        self.points = []  # [(x,y), ...]，游戏坐标
        self.zoom_scale = 1.0  # 1.0 = 0~100 刚好铺满画布
        self.pan_x = 0.0
        self.pan_y = 0.0
        self._dragging = False
        self._last_pos = None
        self.setMinimumSize(300, 300)

    def set_points(self, points):
        """设置要绘制的路径点列表。"""
        self.points = [(float(p[0]), float(p[1])) for p in points] if points else []
        self.update()

    def reset_view(self):
        """重置缩放和平移到初始状态。"""
        self.zoom_scale = 1.0
        self.pan_x = 0.0
        self.pan_y = 0.0
        self.update()

    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self._dragging = True
            self._last_pos = event.position()

    def mouseMoveEvent(self, event):
        if self._dragging and self._last_pos is not None:
            pos = event.position()
            self.pan_x += pos.x() - self._last_pos.x()
            self.pan_y += pos.y() - self._last_pos.y()
            self._last_pos = pos
            self.update()

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self._dragging = False
            self._last_pos = None

    def wheelEvent(self, event):
        """鼠标滚轮：向上放大，向下缩小。"""
        dy = event.angleDelta().y()
        if dy > 0:
            self.zoom_scale = min(self.ZOOM_MAX, self.zoom_scale * self.ZOOM_STEP)
        else:
            self.zoom_scale = max(self.ZOOM_MIN, self.zoom_scale / self.ZOOM_STEP)
        self.update()

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)

        rect = self.rect().adjusted(10, 10, -10, -10)
        if rect.width() <= 0 or rect.height() <= 0:
            return

        # 背景
        painter.fillRect(self.rect(), QColor(30, 30, 30))

        # 固定坐标：左上角 (0,0)，右下角 (100,100)；缩放以画布中心为基准，pan 为拖拽偏移（像素）
        base_scale = min(rect.width(), rect.height()) / 100.0
        scale = base_scale * self.zoom_scale
        cx, cy = rect.center().x(), rect.center().y()

        def to_canvas(p):
            x, y = p
            px = cx + self.pan_x + (x - 50.0) * scale
            py = cy + self.pan_y + (y - 50.0) * scale
            return px, py

        # 坐标边框（按缩放后的逻辑范围绘制）
        painter.setPen(QPen(QColor(80, 80, 80), 1))
        x0, y0 = to_canvas((0, 0))
        x1, y1 = to_canvas((100, 100))
        painter.drawRect(int(x0), int(y0), int(x1 - x0), int(y1 - y0))

        if not self.points:
            painter.setPen(QPen(QColor(200, 200, 200), 1))
            painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "无巡逻点数据")
            return

        # 绘制路径连线
        painter.setPen(QPen(QColor(0, 200, 255), 2))
        for i in range(len(self.points) - 1):
            x1, y1 = to_canvas(self.points[i])
            x2, y2 = to_canvas(self.points[i + 1])
            painter.drawLine(int(x1), int(y1), int(x2), int(y2))

        # 绘制所有巡逻点
        painter.setPen(QPen(QColor(255, 255, 0), 1))
        painter.setBrush(QColor(255, 255, 0))
        for p in self.points:
            px, py = to_canvas(p)
            painter.drawEllipse(int(px) - 2, int(py) - 2, 4, 4)

        # 起点与终点标记（覆盖在普通点之上）
        start_x, start_y = to_canvas(self.points[0])
        end_x, end_y = to_canvas(self.points[-1])
        painter.setPen(QPen(QColor(0, 255, 0), 2))
        painter.setBrush(QColor(0, 255, 0))
        painter.drawEllipse(int(start_x) - 4, int(start_y) - 4, 8, 8)

        painter.setPen(QPen(QColor(255, 80, 80), 2))
        painter.setBrush(QColor(255, 80, 80))
        painter.drawEllipse(int(end_x) - 4, int(end_y) - 4, 8, 8)


# ============ 多键控制 ============

class KeyConfigWidget(QWidget):
    """单个按键配置组件"""

    def __init__(self, key_name, default_config):
        super().__init__()
        self.key_name = key_name
        self.config = default_config or {'enabled': False, 'interval': 10, 'jitter': 2}
        self.init_ui()

    def init_ui(self):
        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        self.checkbox = QCheckBox(f"按键 [{self.key_name}]")
        self.checkbox.setChecked(self.config['enabled'])
        layout.addWidget(self.checkbox)
        layout.addWidget(QLabel("间隔:"))
        self.interval_spin = QSpinBox()
        self.interval_spin.setRange(1, 60)
        self.interval_spin.setValue(self.config['interval'])
        layout.addWidget(self.interval_spin)
        layout.addWidget(QLabel("秒"))
        layout.addWidget(QLabel("随机:"))
        self.jitter_spin = QSpinBox()
        self.jitter_spin.setRange(0, 10)
        self.jitter_spin.setValue(self.config['jitter'])
        layout.addWidget(self.jitter_spin)
        layout.addWidget(QLabel("秒"))
        layout.addStretch()
        self.checkbox.toggled.connect(self.on_toggle)
        self.on_toggle()

    def on_toggle(self):
        enabled = self.checkbox.isChecked()
        self.interval_spin.setEnabled(enabled)
        self.jitter_spin.setEnabled(enabled)

    def get_config(self):
        return {
            'enabled': self.checkbox.isChecked(),
            'interval': self.interval_spin.value(),
            'jitter': self.jitter_spin.value()
        }


class KeyWorker(QThread):
    """按键工作线程"""
    log_signal = pyqtSignal(str)

    def __init__(self, hwnd, key_name, config):
        super().__init__()
        self.hwnd = hwnd
        self.key_name = key_name
        self.config = config
        self.running = True

    def run(self):
        while self.running:
            if self.config['enabled']:
                self.press_key()
            sleep_time = self.config['interval'] + random.uniform(
                -self.config['jitter'], self.config['jitter']
            )
            sleep_time = max(1, sleep_time)
            for _ in range(int(sleep_time * 2)):
                if not self.running:
                    break
                time.sleep(0.5)

    def press_key(self):
        if not self.hwnd:
            return
        key_map = {
            '空格(跳跃)': win32con.VK_SPACE,
            '1': 0x31, '2': 0x32, '3': 0x33, '4': 0x34, '5': 0x35
        }
        vk = key_map.get(self.key_name)
        if vk:
            try:
                win32api.PostMessage(self.hwnd, win32con.WM_KEYDOWN, vk, 0)
                time.sleep(random.uniform(0.05, 0.15))
                win32api.PostMessage(self.hwnd, win32con.WM_KEYUP, vk, 0)
                key_type = "跳跃" if self.key_name == '空格(跳跃)' else f"技能 {self.key_name}"
                self.log_signal.emit(f"➡️ 执行 {key_type}")
            except Exception as e:
                self.log_signal.emit(f"❌ 按键 {self.key_name} 失败: {e}")


# ============ 游戏主循环（状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 录制） ============

class GameLoopWorker(QThread):
    """游戏状态主循环：支持状态监控、巡逻打怪、自动打怪、任务跟随、飞行模式、巡逻点录制"""
    state_signal = pyqtSignal(str)
    log_signal = pyqtSignal(str)
    stop_signal = pyqtSignal()  # 炉石回城后触发，等同于按下停止按钮

    def __init__(
        self,
        mode,
        waypoints_path=None,
        vendor_path=None,
        record_filename=None,
        record_min_distance=None,
        attack_loop_path=None,
        skinning_wait_sec=None,
        food_key=None,
        eat_hp_threshold=None,
        eat_max_wait_sec=None,
        quest_follow_follow_key=None,
        quest_follow_interact_key=None,
        quest_follow_interval_sec=None,
        flight_json_path=None,
        flight_takeoff_key=None,
        flight_takeoff_hold_sec=None,
        flight_land_key=None,
        flight_land_hold_sec=None,
    ):
        super().__init__()
        self.mode = mode  # 'monitor' | 'patrol' | 'combat' | 'quest_follow' | 'flight' | 'record'
        self.running = True
        self.bot_move = None
        self.bot_combat = None
        self.quest_follow_bot = None
        self.flight_bot = None
        self.recorder = None
        self.waypoints_path = waypoints_path
        self.vendor_path = vendor_path
        self.record_filename = record_filename or 'waypoints'
        self.record_min_distance = record_min_distance
        self.attack_loop_path = attack_loop_path or None
        self.skinning_wait_sec = skinning_wait_sec
        self.food_key = (food_key or "f1").strip().lower() or "f1"
        try:
            self.eat_hp_threshold = int(eat_hp_threshold)
        except (TypeError, ValueError):
            self.eat_hp_threshold = 30
        try:
            self.eat_max_wait_sec = float(eat_max_wait_sec)
        except (TypeError, ValueError):
            self.eat_max_wait_sec = 30.0
        self.quest_follow_follow_key = (quest_follow_follow_key or "f").strip().lower() or "f"
        self.quest_follow_interact_key = (quest_follow_interact_key or "4").strip().lower() or "4"
        try:
            self.quest_follow_interval_sec = float(quest_follow_interval_sec)
        except (TypeError, ValueError):
            self.quest_follow_interval_sec = 3.0
        self.flight_json_path = flight_json_path
        self.flight_takeoff_key = flight_takeoff_key
        self.flight_takeoff_hold_sec = flight_takeoff_hold_sec
        self.flight_land_key = flight_land_key
        self.flight_land_hold_sec = flight_land_hold_sec

    def run(self):
        try:
            from game_state import parse_game_state, format_game_state_line
        except ImportError:
            self.log_signal.emit("❌ 无法导入 game_state 模块")
            return

        if self.mode == 'patrol':
            try:
                from auto_bot_move import AutoBotMove, load_waypoints
                self.bot_move = AutoBotMove(
                    waypoints_path=self.waypoints_path,
                    vendor_path=self.vendor_path,
                    attack_loop_path=self.attack_loop_path,
                    skinning_wait_sec=self.skinning_wait_sec,
                    food_key=self.food_key,
                    eat_hp_threshold=self.eat_hp_threshold,
                    eat_max_wait_sec=self.eat_max_wait_sec,
                    stop_check=lambda: not self.running,
                )
                self.bot_move._on_hearthstone_stop = self.stop_signal.emit
            except ImportError as e:
                self.log_signal.emit(f"❌ 巡逻打怪依赖加载失败: {e}")
                return

        if self.mode == 'combat':
            try:
                from auto_bot import AutoBot
                self.bot_combat = AutoBot(
                    attack_loop_path=self.attack_loop_path,
                    skinning_wait_sec=self.skinning_wait_sec,
                )
            except ImportError as e:
                self.log_signal.emit(f"❌ 自动打怪依赖加载失败: {e}")
                return

        if self.mode == 'quest_follow':
            try:
                from quest_follow import QuestFollowBot
                self.quest_follow_bot = QuestFollowBot(
                    follow_key=self.quest_follow_follow_key,
                    interact_key=self.quest_follow_interact_key,
                    interval_sec=self.quest_follow_interval_sec,
                    log_callback=lambda m: self.log_signal.emit(m),
                )
            except ImportError as e:
                self.log_signal.emit(f"❌ 任务跟随依赖加载失败: {e}")
                return

        if self.mode == 'flight':
            if not self.flight_json_path:
                self.log_signal.emit("❌ 飞行模式：未选择航线 JSON")
                return
            try:
                from flight_mode import FlightModeBot
                self.flight_bot = FlightModeBot(
                    json_path=self.flight_json_path,
                    log_callback=lambda m: self.log_signal.emit(m),
                    takeoff_key=self.flight_takeoff_key,
                    takeoff_hold_sec=self.flight_takeoff_hold_sec,
                    land_key=self.flight_land_key,
                    land_hold_sec=self.flight_land_hold_sec,
                )
            except ImportError as e:
                self.log_signal.emit(f"❌ 飞行模式依赖加载失败: {e}")
                return

        if self.mode == 'return_repair':
            if not self.vendor_path:
                self.log_signal.emit("❌ 回城修理模式：未选择修理商 JSON")
                return
            try:
                from logistics_manager import LogisticsManager
                from coordinate_patrol import CoordinatePatrol
                from game_state import load_layout_config

                layout = load_layout_config()
                self.patrol_controller = CoordinatePatrol(
                    waypoints=[(0.0, 0.0)],
                    mount_key=str(layout.get("mount_key", "x") or "x"),
                    mount_hold_sec=float(layout.get("mount_hold_sec", 1.6)),
                    mount_retry_after_sec=float(layout.get("mount_retry_after_sec", 2.0)),
                    enable_mount=bool(layout.get("enable_mount", True)),
                )
                self.logistics_manager = LogisticsManager(route_file=self.vendor_path)

                self.log_signal.emit(f"⛑️ 回城修理：开始执行路径（{os.path.basename(self.vendor_path)}）")

                get_state = lambda: parse_game_state() if self.running else None
                ok = self.logistics_manager.run_route1_round(
                    get_state,
                    self.patrol_controller,
                    route_file=self.vendor_path,
                )
                if ok:
                    self.log_signal.emit("✅ 回城修理：路径完成")
                else:
                    self.log_signal.emit("⏹️ 回城修理：已中止或未完成")
            except ImportError as e:
                self.log_signal.emit(f"❌ 回城修理依赖加载失败: {e}")
                return
            except Exception as e:
                self.log_signal.emit(f"❌ 回城修理执行失败: {e}")
                return
            finally:
                self.running = False
            return

        if self.mode == 'record':
            try:
                from recorder import WaypointRecorder
                rec_dir = get_recorder_dir()
                name = self.record_filename.strip() or 'waypoints'
                if not name.endswith('.json'):
                    name += '.json'
                wp_path = os.path.join(rec_dir, name)
                self.recorder = WaypointRecorder(
                    min_distance=self.record_min_distance if self.record_min_distance is not None else 0.5,
                    waypoints_file=wp_path,
                    on_point_added=lambda pos, cnt: self.log_signal.emit(
                        f"📌 已记录点 {pos} | 总点数: {cnt}"
                    )
                )
            except ImportError as e:
                self.log_signal.emit(f"❌ 录制依赖加载失败: {e}")
                return

        while self.running:
            state = parse_game_state()
            if state:
                self.state_signal.emit(format_game_state_line(state))

                if self.mode == 'patrol' and self.bot_move:
                    self.bot_move.execute_logic(state)
                elif self.mode == 'combat' and self.bot_combat:
                    self.bot_combat.execute_logic(state)
                elif self.mode == 'record' and self.recorder:
                    self.recorder.record(state)
                elif self.mode == 'flight' and self.flight_bot:
                    self.flight_bot.execute_logic(state)
            if self.mode == 'quest_follow' and self.quest_follow_bot:
                self.quest_follow_bot.execute_logic(state)
            time.sleep(0.1)

    def save_recorder(self):
        """录制模式下保存路径。"""
        if self.recorder:
            return self.recorder.save()
        return None


# ============ 主窗口 ============

class WoWMultiKeyGUI(QMainWindow):
    def __init__(self):
        super().__init__()
        self.config_path = os.path.join(_config_base(), CONFIG_FILE)
        self.config = self.load_config()
        self.key_workers = {}
        self.game_worker = None
        self.pending_recorder = None  # 停止录制后保留，便于保存
        self.hwnd = None

        self.init_ui()
        self.find_wow_window()

    def init_ui(self):
        self.setWindowTitle("WoW 多功能控制器")
        self.setGeometry(100, 100, 780, 620)

        central = QWidget()
        self.setCentralWidget(central)
        layout = QVBoxLayout(central)

        # 窗口状态
        top = QHBoxLayout()
        self.window_label = QLabel("正在查找窗口...")
        top.addWidget(self.window_label)
        self.refresh_btn = QPushButton("刷新窗口")
        self.refresh_btn.clicked.connect(self.find_wow_window)
        top.addWidget(self.refresh_btn)
        top.addStretch()
        layout.addLayout(top)

        # 标签页
        tabs = QTabWidget()
        self.tabs = tabs

        # Tab1: 多键控制
        tab_keys = QWidget()
        keys_layout = QVBoxLayout(tab_keys)
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll_widget = QWidget()
        scroll_layout = QVBoxLayout(scroll_widget)
        self.key_widgets = {}
        keys_cfg = (self.config or {}).get('keys') or {}
        for key in ['空格(跳跃)', '1', '2', '3', '4', '5']:
            w = KeyConfigWidget(key, keys_cfg.get(key))
            scroll_layout.addWidget(w)
            self.key_widgets[key] = w
        scroll_layout.addStretch()
        scroll.setWidget(scroll_widget)
        keys_layout.addWidget(scroll)
        keys_btn = QHBoxLayout()
        self.keys_start_btn = QPushButton("🚀 启动多键")
        self.keys_start_btn.clicked.connect(self.start_keys)
        self.keys_stop_btn = QPushButton("⏹ 停止多键")
        self.keys_stop_btn.clicked.connect(self.stop_keys)
        self.keys_stop_btn.setEnabled(False)
        keys_btn.addWidget(self.keys_start_btn)
        keys_btn.addWidget(self.keys_stop_btn)
        keys_btn.addStretch()
        keys_layout.addLayout(keys_btn)
        tabs.addTab(tab_keys, "多键控制")

        # Tab2: 状态 / 巡逻 / 打怪
        tab_game = QWidget()
        game_layout = QVBoxLayout(tab_game)

        mode_group = QGroupBox("模式")
        mode_layout = QHBoxLayout(mode_group)
        self.mode_btns = {}
        for mid, name in [
            ('monitor', '状态监控'),
            ('patrol', '巡逻打怪'),
            ('combat', '自动打怪'),
            ('quest_follow', '任务跟随'),
            ('flight', '飞行模式'),
            ('return_repair', '回城修理'),
        ]:
            btn = QPushButton(name)
            btn.setCheckable(True)
            btn.clicked.connect(lambda checked, m=mid: self.set_game_mode(m))
            mode_layout.addWidget(btn)
            self.mode_btns[mid] = btn
        mode_layout.addStretch()
        game_layout.addWidget(mode_group)

        # 巡逻打怪配置（仅选择巡逻打怪模式时显示）
        self.patrol_group = QGroupBox("巡逻打怪配置")
        patrol_layout = QFormLayout(self.patrol_group)
        self.waypoints_combo = QComboBox()
        self.waypoints_combo.setMinimumWidth(200)
        self.vendor_combo = QComboBox()
        self.vendor_combo.setMinimumWidth(200)
        self.patrol_attack_loop_combo = QComboBox()
        self.patrol_attack_loop_combo.setMinimumWidth(200)
        self._refresh_recorder_combos()
        refresh_btn = QPushButton("🔄 刷新列表")
        refresh_btn.clicked.connect(self._refresh_recorder_combos)
        patrol_layout.addRow("巡逻点 JSON:", self.waypoints_combo)
        patrol_layout.addRow("修理商 JSON:", self.vendor_combo)
        patrol_layout.addRow("攻击循环:", self.patrol_attack_loop_combo)
        patrol_layout.addRow("", refresh_btn)
        self.patrol_group.setVisible(False)
        game_layout.addWidget(self.patrol_group)

        # 自动打怪配置（仅选择自动打怪模式时显示）
        self.combat_group = QGroupBox("自动打怪配置")
        combat_layout = QFormLayout(self.combat_group)
        self.combat_attack_loop_combo = QComboBox()
        self.combat_attack_loop_combo.setMinimumWidth(200)
        combat_layout.addRow("攻击循环:", self.combat_attack_loop_combo)
        self.combat_group.setVisible(False)
        game_layout.addWidget(self.combat_group)

        qf_cfg = ((self.config or {}).get('bot') or {}).get('quest_follow') or {}
        self.quest_follow_group = QGroupBox("任务跟随配置")
        qf_layout = QFormLayout(self.quest_follow_group)
        self.quest_follow_follow_edit = QLineEdit()
        self.quest_follow_follow_edit.setPlaceholderText("如 f（跟随队友）")
        self.quest_follow_follow_edit.setMaxLength(8)
        self.quest_follow_follow_edit.setText(str(qf_cfg.get('follow_key', 'f')).strip() or 'f')
        self.quest_follow_interact_edit = QLineEdit()
        self.quest_follow_interact_edit.setPlaceholderText("如 4（与互动键一致）")
        self.quest_follow_interact_edit.setMaxLength(8)
        self.quest_follow_interact_edit.setText(str(qf_cfg.get('interact_key', '4')).strip() or '4')
        self.quest_follow_interval_spin = QDoubleSpinBox()
        self.quest_follow_interval_spin.setRange(0.5, 120.0)
        self.quest_follow_interval_spin.setSingleStep(0.5)
        try:
            self.quest_follow_interval_spin.setValue(float(qf_cfg.get('interval_sec', 3.0)))
        except (TypeError, ValueError):
            self.quest_follow_interval_spin.setValue(3.0)
        self.quest_follow_interval_spin.setSuffix(" 秒")
        qf_layout.addRow("跟随键:", self.quest_follow_follow_edit)
        qf_layout.addRow("交互键:", self.quest_follow_interact_edit)
        qf_layout.addRow("按键间隔:", self.quest_follow_interval_spin)
        self.quest_follow_group.setVisible(False)
        game_layout.addWidget(self.quest_follow_group)

        ff_cfg = ((self.config or {}).get('bot') or {}).get('flight') or {}
        self.flight_group = QGroupBox("飞行模式配置")
        flight_layout = QFormLayout(self.flight_group)
        self.flight_json_combo = QComboBox()
        self.flight_json_combo.setMinimumWidth(200)
        self._refresh_flight_json_combo()
        flight_refresh_btn = QPushButton("🔄 刷新列表")
        flight_refresh_btn.clicked.connect(self._refresh_flight_json_combo)
        flight_layout.addRow("航线 JSON（取最后一点为目的地）:", self.flight_json_combo)

        self.flight_takeoff_key_edit = QLineEdit()
        self.flight_takeoff_key_edit.setPlaceholderText("如 space 或 空格")
        self.flight_takeoff_key_edit.setMaxLength(16)
        self.flight_takeoff_key_edit.setText(str(ff_cfg.get('takeoff_key', 'space')).strip() or 'space')

        self.flight_takeoff_hold_spin = QDoubleSpinBox()
        self.flight_takeoff_hold_spin.setRange(0.1, 120.0)
        self.flight_takeoff_hold_spin.setSingleStep(0.1)
        try:
            self.flight_takeoff_hold_spin.setValue(float(ff_cfg.get('takeoff_hold_sec', 2.0)))
        except (TypeError, ValueError):
            self.flight_takeoff_hold_spin.setValue(2.0)
        self.flight_takeoff_hold_spin.setSuffix(" 秒")

        self.flight_land_key_edit = QLineEdit()
        self.flight_land_key_edit.setPlaceholderText("如 p")
        self.flight_land_key_edit.setMaxLength(16)
        self.flight_land_key_edit.setText(str(ff_cfg.get('land_key', 'p')).strip() or 'p')

        self.flight_land_hold_spin = QDoubleSpinBox()
        self.flight_land_hold_spin.setRange(0.1, 120.0)
        self.flight_land_hold_spin.setSingleStep(0.1)
        try:
            self.flight_land_hold_spin.setValue(float(ff_cfg.get('land_hold_sec', 2.0)))
        except (TypeError, ValueError):
            self.flight_land_hold_spin.setValue(2.0)
        self.flight_land_hold_spin.setSuffix(" 秒")

        flight_layout.addRow("起飞按键:", self.flight_takeoff_key_edit)
        flight_layout.addRow("起飞按住时长:", self.flight_takeoff_hold_spin)
        flight_layout.addRow("降落按键:", self.flight_land_key_edit)
        flight_layout.addRow("降落按住时长:", self.flight_land_hold_spin)

        flight_layout.addRow("", flight_refresh_btn)
        self.flight_group.setVisible(False)
        game_layout.addWidget(self.flight_group)

        # 回城修理配置（在飞行模式之后新增一个独立模式）
        self.repair_group = QGroupBox("回城修理配置")
        repair_layout = QFormLayout(self.repair_group)
        self.repair_vendor_combo = QComboBox()
        self.repair_vendor_combo.setMinimumWidth(200)
        self._refresh_repair_vendor_json_combo()
        repair_refresh_btn = QPushButton("🔄 刷新列表")
        repair_refresh_btn.clicked.connect(self._refresh_repair_vendor_json_combo)
        repair_layout.addRow("修理商 JSON:", self.repair_vendor_combo)
        repair_layout.addRow("", repair_refresh_btn)
        self.repair_group.setVisible(False)
        game_layout.addWidget(self.repair_group)

        self.state_label = QLabel("状态: ---")
        self.state_label.setStyleSheet("font-family: monospace; padding: 6px; background: #1e1e1e; color: #d4d4d4;")
        self.state_label.setMinimumHeight(36)
        game_layout.addWidget(self.state_label)

        game_btn = QHBoxLayout()
        self.game_start_btn = QPushButton("▶ 启动")
        self.game_start_btn.clicked.connect(self.start_game_loop)
        self.game_stop_btn = QPushButton("⏹ 停止")
        self.game_stop_btn.clicked.connect(self.stop_game_loop)
        self.game_stop_btn.setEnabled(False)
        game_btn.addWidget(self.game_start_btn)
        game_btn.addWidget(self.game_stop_btn)
        game_btn.addStretch()
        game_layout.addLayout(game_btn)

        tabs.addTab(tab_game, "状态/巡逻/打怪")

        # Tab3: 巡逻点录制
        tab_record = QWidget()
        record_layout = QVBoxLayout(tab_record)

        record_name_group = QGroupBox("录制配置")
        record_name_layout = QFormLayout(record_name_group)
        self.record_name_edit = QLineEdit()
        self.record_name_edit.setPlaceholderText(f"输入名称，保存到 {RECORDER_DIR}/ 文件夹（如 route1）")
        self.record_name_edit.setText("waypoints")
        record_name_layout.addRow("录制名称:", self.record_name_edit)
        self.record_min_distance_spin = QDoubleSpinBox()
        self.record_min_distance_spin.setRange(0.1, 20.0)
        self.record_min_distance_spin.setSingleStep(0.1)
        self.record_min_distance_spin.setValue(0.5)
        self.record_min_distance_spin.setSuffix("（游戏坐标）")
        record_name_layout.addRow("最小记录间距:", self.record_min_distance_spin)
        record_layout.addWidget(record_name_group)

        self.record_state_label = QLabel("状态: ---")
        self.record_state_label.setStyleSheet("font-family: monospace; padding: 6px; background: #1e1e1e; color: #d4d4d4;")
        self.record_state_label.setMinimumHeight(36)
        record_layout.addWidget(self.record_state_label)

        record_btn = QHBoxLayout()
        self.record_start_btn = QPushButton("▶ 开始录制")
        self.record_start_btn.clicked.connect(self.start_record)
        self.record_stop_btn = QPushButton("⏹ 停止录制")
        self.record_stop_btn.clicked.connect(self.stop_record)
        self.record_stop_btn.setEnabled(False)
        self.record_save_btn = QPushButton("💾 保存路径")
        self.record_save_btn.clicked.connect(self.save_recorder)
        self.record_save_btn.setEnabled(False)
        record_btn.addWidget(self.record_start_btn)
        record_btn.addWidget(self.record_stop_btn)
        record_btn.addWidget(self.record_save_btn)
        record_btn.addStretch()
        record_layout.addWidget(QLabel("在游戏内跑动，程序会自动记录坐标点。"))
        record_layout.addLayout(record_btn)

        tabs.addTab(tab_record, "巡逻点录制")

        # Tab4: 巡逻点预览
        tab_preview = QWidget()
        preview_layout = QVBoxLayout(tab_preview)

        preview_file_group = QGroupBox("巡逻点文件")
        preview_file_layout = QFormLayout(preview_file_group)
        self.preview_waypoints_combo = QComboBox()
        self.preview_waypoints_combo.setMinimumWidth(220)
        self._refresh_preview_waypoints()
        self.preview_waypoints_combo.currentIndexChanged.connect(self._on_preview_waypoints_changed)
        preview_file_layout.addRow("选择 JSON:", self.preview_waypoints_combo)
        preview_layout.addWidget(preview_file_group)

        self.preview_canvas = WaypointPreviewCanvas()
        preview_layout.addWidget(self.preview_canvas, stretch=1)

        preview_btn_row = QHBoxLayout()
        preview_btn_row.addStretch()
        self.preview_reset_btn = QPushButton("重置视图")
        self.preview_reset_btn.clicked.connect(self._reset_preview_canvas)
        preview_btn_row.addWidget(self.preview_reset_btn)
        preview_layout.addLayout(preview_btn_row)

        tabs.addTab(tab_preview, "巡逻点预览")

        # Tab: 攻击循环（可自定义名称保存，巡逻/自动打怪时可选）
        tab_combat_loops = QWidget()
        loops_layout = QVBoxLayout(tab_combat_loops)
        loops_top = QHBoxLayout()
        self.combat_loops_list = QComboBox()
        self.combat_loops_list.setMinimumWidth(220)
        self.combat_loops_list.currentIndexChanged.connect(self._on_combat_loop_selected)
        loops_btn = QPushButton("🔄 刷新")
        loops_btn.clicked.connect(self._refresh_combat_loops_tab)
        loops_new_btn = QPushButton("新建")
        loops_new_btn.clicked.connect(self._combat_loop_new)
        loops_del_btn = QPushButton("删除")
        loops_del_btn.clicked.connect(self._combat_loop_delete)
        loops_top.addWidget(QLabel("已保存:"))
        loops_top.addWidget(self.combat_loops_list)
        loops_top.addWidget(loops_btn)
        loops_top.addWidget(loops_new_btn)
        loops_top.addWidget(loops_del_btn)
        loops_top.addStretch()
        loops_layout.addLayout(loops_top)
        loop_editor = QGroupBox("编辑攻击循环")
        loop_editor_layout = QFormLayout(loop_editor)
        self.combat_loop_name_edit = QLineEdit()
        self.combat_loop_name_edit.setPlaceholderText("输入名称保存（如 默认循环）")
        self.combat_loop_trigger = QDoubleSpinBox()
        self.combat_loop_trigger.setRange(0.01, 1.0)
        self.combat_loop_trigger.setSingleStep(0.1)
        self.combat_loop_trigger.setValue(0.3)
        self.combat_loop_trigger.setSuffix(" 触发概率")
        self.combat_loop_hp_below = QSpinBox()
        self.combat_loop_hp_below.setRange(0, 100)
        self.combat_loop_hp_below.setValue(0)
        self.combat_loop_hp_below.setSuffix(" %（0=不启用）")
        self.combat_loop_hp_key = QLineEdit()
        self.combat_loop_hp_key.setPlaceholderText("如 5")
        self.combat_loop_hp_key.setMaxLength(3)
        self.combat_loop_mp_below = QSpinBox()
        self.combat_loop_mp_below.setRange(0, 100)
        self.combat_loop_mp_below.setValue(0)
        self.combat_loop_mp_below.setSuffix(" %（0=不启用）")
        self.combat_loop_mp_key = QLineEdit()
        self.combat_loop_mp_key.setPlaceholderText("如 6")
        self.combat_loop_mp_key.setMaxLength(3)
        self.combat_loop_steps = QTableWidget(0, 2)
        self.combat_loop_steps.setHorizontalHeaderLabels(["按键", "延迟(秒)"])
        self.combat_loop_steps.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
        self.combat_loop_steps.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        loop_steps_btn = QHBoxLayout()
        loop_add_row_btn = QPushButton("+ 添加一步")
        loop_add_row_btn.clicked.connect(self._combat_loop_add_step)
        loop_remove_row_btn = QPushButton("- 删除选中")
        loop_remove_row_btn.clicked.connect(self._combat_loop_remove_step)
        loop_steps_btn.addWidget(loop_add_row_btn)
        loop_steps_btn.addWidget(loop_remove_row_btn)
        loop_steps_btn.addStretch()
        loop_editor_layout.addRow("名称:", self.combat_loop_name_edit)
        loop_editor_layout.addRow("触发概率:", self.combat_loop_trigger)
        loop_editor_layout.addRow("血量低于:", self.combat_loop_hp_below)
        loop_editor_layout.addRow("血量按键:", self.combat_loop_hp_key)
        loop_editor_layout.addRow("蓝量低于:", self.combat_loop_mp_below)
        loop_editor_layout.addRow("蓝量按键:", self.combat_loop_mp_key)
        loop_editor_layout.addRow("步骤:", self.combat_loop_steps)
        loop_editor_layout.addRow("", loop_steps_btn)
        loop_save_btn = QPushButton("💾 保存")
        loop_save_btn.clicked.connect(self._combat_loop_save)
        loop_editor_layout.addRow("", loop_save_btn)
        loops_layout.addWidget(loop_editor)
        tabs.addTab(tab_combat_loops, "攻击循环")

        # Tab: 参数配置（多分辨率，两列布局）
        tab_params = QWidget()
        params_layout = QVBoxLayout(tab_params)
        params_content = QWidget()
        params_row = QHBoxLayout(params_content)
        params_left = QFormLayout()
        params_right = QFormLayout()
        self.gs_pixel_size = QSpinBox()
        self.gs_pixel_size.setRange(8, 32)
        self.gs_pixel_size.setValue(17)
        self.gs_block_start_x = QSpinBox()
        self.gs_block_start_x.setRange(0, 100)
        self.gs_block_start_x.setValue(30)
        self.gs_scan_width = QSpinBox()
        self.gs_scan_width.setRange(80, 300)
        self.gs_scan_width.setValue(155)
        self.gs_scan_height = QSpinBox()
        self.gs_scan_height.setRange(10, 40)
        self.gs_scan_height.setValue(15)
        self.gs_offset_left = QSpinBox()
        self.gs_offset_left.setRange(0, 100)
        self.gs_offset_left.setValue(20)
        self.gs_offset_top = QSpinBox()
        self.gs_offset_top.setRange(0, 100)
        self.gs_offset_top.setValue(45)
        params_left.addRow("每格像素 (pixel_size):", self.gs_pixel_size)
        params_left.addRow("起始 X (block_start_x):", self.gs_block_start_x)
        params_left.addRow("截图宽度 (scan_region_width):", self.gs_scan_width)
        params_left.addRow("截图高度 (scan_region_height):", self.gs_scan_height)
        params_left.addRow("窗口左偏移 (offset_left):", self.gs_offset_left)
        params_left.addRow("窗口顶偏移 (offset_top):", self.gs_offset_top)

        # Bot 参数（保存到 wow_multikey_qt.json）
        self.skinning_wait_spin = QDoubleSpinBox()
        self.skinning_wait_spin.setRange(0.1, 10.0)
        self.skinning_wait_spin.setSingleStep(0.1)
        self.skinning_wait_spin.setValue(1.5)
        self.skinning_wait_spin.setSuffix(" 秒")
        params_right.addRow("剥皮等待时间:", self.skinning_wait_spin)

        self.food_key_edit = QLineEdit()
        self.food_key_edit.setPlaceholderText("如 f1")
        self.food_key_edit.setMaxLength(16)
        self.food_key_edit.setText("f1")
        self.eat_hp_threshold_spin = QSpinBox()
        self.eat_hp_threshold_spin.setRange(1, 100)
        self.eat_hp_threshold_spin.setValue(30)
        self.eat_hp_threshold_spin.setSuffix(" %")
        self.eat_max_wait_spin = QDoubleSpinBox()
        self.eat_max_wait_spin.setRange(1.0, 120.0)
        self.eat_max_wait_spin.setSingleStep(1.0)
        self.eat_max_wait_spin.setValue(30.0)
        self.eat_max_wait_spin.setSuffix(" 秒")
        params_right.addRow("吃面包按键:", self.food_key_edit)
        params_right.addRow("吃面包血量阈值:", self.eat_hp_threshold_spin)
        params_right.addRow("吃面包最长等待:", self.eat_max_wait_spin)

        self.gs_hearthstone_key = QLineEdit()
        self.gs_hearthstone_key.setPlaceholderText("如 b")
        self.gs_hearthstone_key.setMaxLength(16)
        self.gs_hearthstone_key.setText("b")
        self.gs_bag_full_hearthstone = QCheckBox("包满时用炉石回城并停止")
        self.gs_bag_full_hearthstone.setChecked(False)
        params_right.addRow("炉石按键 (hearthstone_key):", self.gs_hearthstone_key)
        params_right.addRow("包满是否炉石回城:", self.gs_bag_full_hearthstone)

        self.gs_enable_mount = QCheckBox("启用上马")
        self.gs_enable_mount.setChecked(True)
        params_right.addRow("是否上马 (enable_mount):", self.gs_enable_mount)
        self.gs_mount_key = QLineEdit()
        self.gs_mount_key.setPlaceholderText("如 x")
        self.gs_mount_key.setMaxLength(16)
        self.gs_mount_key.setText("x")
        self.gs_mount_hold = QDoubleSpinBox()
        self.gs_mount_hold.setRange(0.1, 30.0)
        self.gs_mount_hold.setSingleStep(0.1)
        self.gs_mount_hold.setValue(1.6)
        self.gs_mount_hold.setSuffix(" 秒")
        self.gs_mount_retry = QDoubleSpinBox()
        self.gs_mount_retry.setRange(0.0, 60.0)
        self.gs_mount_retry.setSingleStep(0.1)
        self.gs_mount_retry.setValue(2.0)
        self.gs_mount_retry.setSuffix(" 秒")
        params_right.addRow("上马按键 (mount_key):", self.gs_mount_key)
        params_right.addRow("上马按住时长 (mount_hold_sec):", self.gs_mount_hold)
        params_right.addRow("上马重试间隔 (mount_retry_after_sec):", self.gs_mount_retry)

        params_row.addLayout(params_left)
        params_row.addLayout(params_right)
        params_layout.addWidget(params_content)
        params_save_btn = QPushButton("💾 保存配置")
        params_save_btn.clicked.connect(self._save_params_config)
        params_layout.addWidget(params_save_btn)
        tabs.addTab(tab_params, "参数配置")

        tabs.currentChanged.connect(self._on_tab_changed)

        layout.addWidget(tabs)

        # 日志
        self.log_text = QTextEdit()
        self.log_text.setReadOnly(True)
        self.log_text.setMaximumHeight(140)
        layout.addWidget(self.log_text)

        self.status_bar = self.statusBar()
        self.status_bar.showMessage("就绪")

        self._current_game_mode = None

    def _load_params_config(self):
        """从 game_state_config.json + wow_multikey_qt.json 加载参数到「参数配置」界面"""
        try:
            from game_state import load_layout_config
            cfg = load_layout_config()
            self.gs_pixel_size.setValue(cfg.get('pixel_size', 17))
            self.gs_block_start_x.setValue(cfg.get('block_start_x', 30))
            self.gs_scan_width.setValue(cfg.get('scan_region_width', 155))
            self.gs_scan_height.setValue(cfg.get('scan_region_height', 15))
            self.gs_offset_left.setValue(cfg.get('offset_left', 20))
            self.gs_offset_top.setValue(cfg.get('offset_top', 45))
            self.gs_mount_key.setText(str(cfg.get('mount_key', 'x') or 'x'))
            self.gs_enable_mount.setChecked(bool(cfg.get('enable_mount', True)))
            self.gs_mount_hold.setValue(float(cfg.get('mount_hold_sec', 1.6)))
            self.gs_hearthstone_key.setText(str(cfg.get('hearthstone_key', 'b') or 'b'))
            self.gs_bag_full_hearthstone.setChecked(bool(cfg.get('bag_full_hearthstone', False)))
            self.gs_mount_retry.setValue(float(cfg.get('mount_retry_after_sec', 2.0)))
        except Exception as e:
            self.log(f"加载参数配置失败: {e}")
        try:
            bot_cfg = (self.config or {}).get('bot') or {}
            v = bot_cfg.get('skinning_wait_sec', 1.5)
            self.skinning_wait_spin.setValue(float(v))
            self.food_key_edit.setText(str(bot_cfg.get('food_key', 'f1')).strip() or 'f1')
            self.eat_hp_threshold_spin.setValue(int(bot_cfg.get('eat_hp_threshold', 30)))
            self.eat_max_wait_spin.setValue(float(bot_cfg.get('eat_max_wait_sec', 30.0)))
        except Exception:
            self.skinning_wait_spin.setValue(1.5)
            self.food_key_edit.setText('f1')
            self.eat_hp_threshold_spin.setValue(30)
            self.eat_max_wait_spin.setValue(30.0)

    def _save_params_config(self):
        """保存「参数配置」界面到 game_state_config.json（多分辨率）并写入 wow_multikey_qt.json（bot 参数）"""
        try:
            from game_state import load_layout_config, save_layout_config
            cfg = load_layout_config()
            cfg['pixel_size'] = self.gs_pixel_size.value()
            cfg['block_start_x'] = self.gs_block_start_x.value()
            cfg['scan_region_width'] = self.gs_scan_width.value()
            cfg['scan_region_height'] = self.gs_scan_height.value()
            cfg['offset_left'] = self.gs_offset_left.value()
            cfg['offset_top'] = self.gs_offset_top.value()
            cfg['mount_key'] = (self.gs_mount_key.text().strip() or 'x')
            cfg['enable_mount'] = self.gs_enable_mount.isChecked()
            cfg['mount_hold_sec'] = float(self.gs_mount_hold.value())
            cfg['mount_retry_after_sec'] = float(self.gs_mount_retry.value())
            cfg['hearthstone_key'] = (self.gs_hearthstone_key.text().strip() or 'b')
            cfg['bag_full_hearthstone'] = self.gs_bag_full_hearthstone.isChecked()
            path = save_layout_config(cfg)
            # bot 参数写入主配置文件
            self.config = self.config or {}
            self.config.setdefault('bot', {})
            self.config['bot']['skinning_wait_sec'] = float(self.skinning_wait_spin.value())
            self.config['bot']['food_key'] = self.food_key_edit.text().strip() or 'f1'
            self.config['bot']['eat_hp_threshold'] = int(self.eat_hp_threshold_spin.value())
            self.config['bot']['eat_max_wait_sec'] = float(self.eat_max_wait_spin.value())
            self._save_main_config()

            self.log(f"✅ 参数配置已保存至 {path}，并更新 bot 参数")
            QMessageBox.information(self, "保存成功", f"参数配置已保存至:\n{path}\n\nBot 参数已写入:\n{self.config_path}")
        except Exception as e:
            self.log(f"❌ 保存参数配置失败: {e}")
            QMessageBox.warning(self, "错误", f"保存失败: {e}")

    def _refresh_attack_loop_combo(self):
        """填充巡逻打怪、自动打怪的攻击循环下拉"""
        for combo in (self.patrol_attack_loop_combo, self.combat_attack_loop_combo):
            combo.clear()
            combo.addItem("-- 默认 --", "")
            for name, path in list_combat_loop_files():
                combo.addItem(name, path)

    def _refresh_combat_loops_tab(self):
        """刷新攻击循环 Tab 的已保存列表"""
        self.combat_loops_list.blockSignals(True)
        self.combat_loops_list.clear()
        self.combat_loops_list.addItem("-- 选择或新建 --", "")
        for name, path in list_combat_loop_files():
            self.combat_loops_list.addItem(name, path)
        self.combat_loops_list.blockSignals(False)
        if self.combat_loops_list.count() > 1:
            self.combat_loops_list.setCurrentIndex(1)
        else:
            self.combat_loops_list.setCurrentIndex(0)

    def _on_combat_loop_selected(self, index):
        """选择已保存的攻击循环时加载到编辑器"""
        path = self.combat_loops_list.currentData()
        if not path or not os.path.exists(path):
            return
        try:
            with open(path, 'r', encoding='utf-8') as f:
                cfg = json.load(f)
            name = cfg.get('name', '')
            if not name and path:
                name = os.path.splitext(os.path.basename(path))[0]
            self.combat_loop_name_edit.setText(name)
            self.combat_loop_trigger.setValue(float(cfg.get('trigger_chance', 0.3)))
            self.combat_loop_hp_below.setValue(int(cfg.get('hp_below_percent', 0)) or 0)
            self.combat_loop_hp_key.setText(str(cfg.get('hp_key', '')).strip())
            self.combat_loop_mp_below.setValue(int(cfg.get('mp_below_percent', 0)) or 0)
            self.combat_loop_mp_key.setText(str(cfg.get('mp_key', '')).strip())
            steps = cfg.get('steps') or []
            self.combat_loop_steps.setRowCount(len(steps))
            for i, s in enumerate(steps):
                self.combat_loop_steps.setItem(i, 0, QTableWidgetItem(str(s.get('key', ''))))
                self.combat_loop_steps.setItem(i, 1, QTableWidgetItem(str(s.get('delay', 0.5))))
        except Exception as e:
            self.log(f"加载攻击循环失败: {e}")

    def _combat_loop_new(self):
        """新建攻击循环（清空编辑器）"""
        self.combat_loops_list.setCurrentIndex(0)
        self.combat_loop_name_edit.clear()
        self.combat_loop_trigger.setValue(0.3)
        self.combat_loop_hp_below.setValue(0)
        self.combat_loop_hp_key.clear()
        self.combat_loop_mp_below.setValue(0)
        self.combat_loop_mp_key.clear()
        self.combat_loop_steps.setRowCount(0)

    def _combat_loop_add_step(self):
        """添加一步"""
        row = self.combat_loop_steps.rowCount()
        self.combat_loop_steps.insertRow(row)
        self.combat_loop_steps.setItem(row, 0, QTableWidgetItem("2"))
        self.combat_loop_steps.setItem(row, 1, QTableWidgetItem("0.5"))

    def _combat_loop_remove_step(self):
        """删除选中的行"""
        rows = set(i.row() for i in self.combat_loop_steps.selectedIndexes())
        for r in sorted(rows, reverse=True):
            self.combat_loop_steps.removeRow(r)

    def _combat_loop_save(self):
        """保存当前编辑的攻击循环到 combat_loops/<名称>.json"""
        name = self.combat_loop_name_edit.text().strip()
        if not name:
            QMessageBox.warning(self, "提示", "请输入名称")
            return
        if not name.endswith('.json'):
            name += '.json'
        steps = []
        for row in range(self.combat_loop_steps.rowCount()):
            k = self.combat_loop_steps.item(row, 0)
            d = self.combat_loop_steps.item(row, 1)
            key = (k.text() if k else '').strip() or '2'
            try:
                delay = float(d.text() if d else 0.5)
            except Exception:
                delay = 0.5
            steps.append({'key': key, 'delay': delay})
        hp_below = self.combat_loop_hp_below.value()
        hp_key = self.combat_loop_hp_key.text().strip()
        mp_below = self.combat_loop_mp_below.value()
        mp_key = self.combat_loop_mp_key.text().strip()
        cfg = {
            'name': os.path.splitext(name)[0],
            'trigger_chance': self.combat_loop_trigger.value(),
            'steps': steps,
            'hp_below_percent': hp_below if hp_below and hp_key else 0,
            'hp_key': hp_key if hp_below and hp_key else '',
            'mp_below_percent': mp_below if mp_below and mp_key else 0,
            'mp_key': mp_key if mp_below and mp_key else '',
        }
        path = os.path.join(get_combat_loops_dir(), name)
        try:
            with open(path, 'w', encoding='utf-8') as f:
                json.dump(cfg, f, indent=2, ensure_ascii=False)
            self.log(f"✅ 攻击循环已保存: {path}")
            QMessageBox.information(self, "保存成功", f"已保存至 {path}")
            self._refresh_combat_loops_tab()
            self._refresh_attack_loop_combo()
        except Exception as e:
            self.log(f"❌ 保存攻击循环失败: {e}")
            QMessageBox.warning(self, "错误", f"保存失败: {e}")

    def _combat_loop_delete(self):
        """删除当前选中的攻击循环文件"""
        path = self.combat_loops_list.currentData()
        if not path or not os.path.exists(path):
            QMessageBox.warning(self, "提示", "请先选择要删除的项")
            return
        if QMessageBox.question(self, "确认", f"确定删除 {os.path.basename(path)}？") != QMessageBox.StandardButton.Yes:
            return
        try:
            os.remove(path)
            self.log(f"已删除: {path}")
            self._combat_loop_new()
            self._refresh_combat_loops_tab()
            self._refresh_attack_loop_combo()
        except Exception as e:
            QMessageBox.warning(self, "错误", f"删除失败: {e}")

    def _on_tab_changed(self, index):
        """切换 Tab 时加载参数或刷新下拉列表"""
        if index == 1:  # 状态/巡逻/打怪
            self._refresh_recorder_combos()
            self._refresh_flight_json_combo()
        elif index == 3:  # 巡逻点预览
            self._refresh_preview_waypoints()
        elif index == 4:  # 攻击循环
            self._refresh_combat_loops_tab()
        elif index == 5:  # 参数配置
            self._load_params_config()

    def _refresh_preview_waypoints(self):
        """刷新巡逻点预览下拉列表"""
        self.preview_waypoints_combo.blockSignals(True)
        self.preview_waypoints_combo.clear()
        self.preview_waypoints_combo.addItem("-- 请选择 --", "")
        for name, path in list_recorder_json():
            self.preview_waypoints_combo.addItem(name, path)
        self.preview_waypoints_combo.blockSignals(False)
        if self.preview_waypoints_combo.count() > 1:
            self.preview_waypoints_combo.setCurrentIndex(1)

    def _on_preview_waypoints_changed(self, index):
        """选择巡逻点 JSON 时加载并绘制在画布上"""
        path = self.preview_waypoints_combo.currentData()
        if not path or not os.path.exists(path):
            self.preview_canvas.set_points([])
            return
        try:
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            # 期望格式：[[x,y], ...] 或 [[x,y, ...], ...]
            points = []
            for item in data:
                if isinstance(item, (list, tuple)) and len(item) >= 2:
                    points.append((float(item[0]), float(item[1])))
            self.preview_canvas.set_points(points)
            self.log(f"已加载巡逻点预览: {path} （共 {len(points)} 点）")
        except Exception as e:
            self.log(f"加载巡逻点预览失败: {e}")
            self.preview_canvas.set_points([])

    def _reset_preview_canvas(self):
        """重置巡逻点预览视图（缩放 + 平移）。"""
        if hasattr(self, "preview_canvas") and self.preview_canvas is not None:
            self.preview_canvas.reset_view()

    def _refresh_recorder_combos(self):
        """刷新巡逻点、修理商下拉列表"""
        items = list_recorder_json()
        combos = [(self.waypoints_combo, 'waypoints.json'), (self.vendor_combo, 'vendor.json')]
        if hasattr(self, "repair_vendor_combo"):
            combos.append((self.repair_vendor_combo, 'vendor.json'))
        for combo, default_name in combos:
            combo.clear()
            combo.addItem("-- 请选择 --", "")
            for name, path in items:
                combo.addItem(name, path)
            # 尝试选中默认项
            idx = combo.findData(os.path.join(get_recorder_dir(), default_name))
            if idx >= 0:
                combo.setCurrentIndex(idx)
            elif combo.count() > 1:
                combo.setCurrentIndex(1)

    def _refresh_repair_vendor_json_combo(self):
        """刷新“回城修理配置”的修理商下拉框。"""
        if not hasattr(self, "repair_vendor_combo"):
            return
        items = list_recorder_json()
        self.repair_vendor_combo.blockSignals(True)
        self.repair_vendor_combo.clear()
        self.repair_vendor_combo.addItem("-- 请选择 --", "")
        for name, path in items:
            self.repair_vendor_combo.addItem(name, path)
        self.repair_vendor_combo.blockSignals(False)
        if self.repair_vendor_combo.count() > 1:
            idx = self.repair_vendor_combo.findData(os.path.join(get_recorder_dir(), 'vendor.json'))
            if idx >= 0:
                self.repair_vendor_combo.setCurrentIndex(idx)
            else:
                self.repair_vendor_combo.setCurrentIndex(1)

    def _refresh_flight_json_combo(self):
        """刷新飞行模式航线 JSON 下拉列表"""
        items = list_recorder_json()
        self.flight_json_combo.blockSignals(True)
        self.flight_json_combo.clear()
        self.flight_json_combo.addItem("-- 请选择 --", "")
        for name, path in items:
            self.flight_json_combo.addItem(name, path)
        self.flight_json_combo.blockSignals(False)
        if self.flight_json_combo.count() > 1:
            self.flight_json_combo.setCurrentIndex(1)

    def set_game_mode(self, mode):
        if self.game_worker and self.game_worker.isRunning():
            self.log("请先停止当前模式再切换")
            return
        for mid, btn in self.mode_btns.items():
            btn.setChecked(mid == mode)
        self._current_game_mode = mode
        if mode == 'patrol':
            self._refresh_recorder_combos()
        if mode == 'patrol':
            self._refresh_attack_loop_combo()
        if mode == 'combat':
            self._refresh_attack_loop_combo()
        self.patrol_group.setVisible(mode == 'patrol')
        self.combat_group.setVisible(mode == 'combat')
        self.quest_follow_group.setVisible(mode == 'quest_follow')
        self.flight_group.setVisible(mode == 'flight')
        self.repair_group.setVisible(mode == 'return_repair')
        if mode == 'flight':
            self._refresh_flight_json_combo()
        if mode == 'return_repair':
            self._refresh_repair_vendor_json_combo()

    def load_config(self):
        if os.path.exists(self.config_path):
            try:
                with open(self.config_path, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    # 兼容旧格式：直接是按键配置 dict（没有 keys/bot 包裹）
                    if isinstance(data, dict) and ('keys' not in data) and any(k in data for k in ['1', '2', '3', '4', '5', '空格(跳跃)']):
                        return {'keys': data, 'bot': {}}
                    if isinstance(data, dict):
                        data.setdefault('keys', {})
                        data.setdefault('bot', {})
                        return data
                    return {}
            except Exception:
                pass
        return {'keys': {}, 'bot': {}}

    def save_key_config(self):
        self.config = self.config or {'keys': {}, 'bot': {}}
        self.config['keys'] = {k: w.get_config() for k, w in self.key_widgets.items()}
        self._save_main_config()

    def _save_main_config(self):
        """保存 wow_multikey_qt.json（多键 + bot 参数）"""
        out = self.config or {'keys': {}, 'bot': {}}
        out.setdefault('keys', {})
        out.setdefault('bot', {})
        with open(self.config_path, 'w', encoding='utf-8') as f:
            json.dump(out, f, indent=2, ensure_ascii=False)

    def find_wow_window(self):
        titles = ["魔兽世界", "World of Warcraft"]
        for title in titles:
            self.hwnd = win32gui.FindWindow(None, title)
            if self.hwnd:
                self.window_label.setText(f"✅ 已找到: {title}")
                self.log(f"成功绑定窗口: {title}")
                return
        self.window_label.setText("❌ 未找到游戏窗口")
        self.hwnd = None
        self.log("未找到游戏窗口，请先启动魔兽世界")

    def log(self, message):
        self.log_text.append(f"[{time.strftime('%H:%M:%S')}] {message}")

    def start_keys(self):
        if not self.hwnd:
            QMessageBox.warning(self, "警告", "未找到游戏窗口！请先启动魔兽世界。")
            return
        any_enabled = any(w.get_config()['enabled'] for w in self.key_widgets.values())
        if not any_enabled:
            QMessageBox.warning(self, "警告", "请至少启用一个按键！")
            return
        self.save_key_config()
        for key_name, widget in self.key_widgets.items():
            cfg = widget.get_config()
            if cfg['enabled']:
                worker = KeyWorker(self.hwnd, key_name, cfg)
                worker.log_signal.connect(self.log)
                worker.start()
                self.key_workers[key_name] = worker
        self.keys_start_btn.setEnabled(False)
        self.keys_stop_btn.setEnabled(True)
        self.status_bar.showMessage("🟢 多键运行中")
        self.log("🎮 多键控制已启动")

    def stop_keys(self):
        for w in self.key_workers.values():
            w.running = False
        self.key_workers.clear()
        self.keys_start_btn.setEnabled(True)
        self.keys_stop_btn.setEnabled(False)
        self.status_bar.showMessage("⏹️ 多键已停止")
        self.log("🛑 多键控制已停止")

    def start_game_loop(self):
        mode = self._current_game_mode
        if mode is None:
            QMessageBox.warning(self, "提示", "请先选择模式（状态监控 / 巡逻打怪 / 自动打怪 / 任务跟随 / 飞行模式 / 回城修理）")
            return
        waypoints_path = None
        vendor_path = None
        flight_json_path = None
        if mode == 'patrol':
            wp = self.waypoints_combo.currentData() or ""
            vp = self.vendor_combo.currentData() or ""
            if not wp:
                QMessageBox.warning(self, "提示", "巡逻打怪模式需选择巡逻点 JSON 文件")
                return
            if not vp:
                QMessageBox.warning(self, "提示", "巡逻打怪模式需选择修理商 JSON 文件")
                return
            if not os.path.exists(wp):
                QMessageBox.warning(self, "提示", f"巡逻点文件不存在: {wp}")
                return
            if not os.path.exists(vp):
                QMessageBox.warning(self, "提示", f"修理商文件不存在: {vp}")
                return
            waypoints_path = wp
            vendor_path = vp
        attack_loop_path = None
        if mode == 'patrol':
            attack_loop_path = self.patrol_attack_loop_combo.currentData() or None
        elif mode == 'combat':
            attack_loop_path = self.combat_attack_loop_combo.currentData() or None
        if attack_loop_path and not os.path.exists(attack_loop_path):
            attack_loop_path = None

        if mode == 'quest_follow':
            self.config = self.config or {}
            self.config.setdefault('bot', {})
            self.config['bot']['quest_follow'] = {
                'follow_key': self.quest_follow_follow_edit.text().strip() or 'f',
                'interact_key': self.quest_follow_interact_edit.text().strip() or '4',
                'interval_sec': float(self.quest_follow_interval_spin.value()),
            }
            self._save_main_config()

        if mode == 'return_repair':
            vp = self.repair_vendor_combo.currentData() or ""
            if not vp:
                QMessageBox.warning(self, "提示", "回城修理模式需选择修理商 JSON 文件")
                return
            if not os.path.exists(vp):
                QMessageBox.warning(self, "提示", f"修理商文件不存在: {vp}")
                return
            vendor_path = vp

        if mode == 'flight':
            fp = self.flight_json_combo.currentData() or ""
            if not fp:
                QMessageBox.warning(self, "提示", "飞行模式需选择航线 JSON 文件")
                return
            if not os.path.exists(fp):
                QMessageBox.warning(self, "提示", f"航线文件不存在: {fp}")
                return
            flight_json_path = fp
            self.config = self.config or {}
            self.config.setdefault('bot', {})
            self.config['bot']['flight'] = {
                'takeoff_key': self.flight_takeoff_key_edit.text().strip() or 'space',
                'takeoff_hold_sec': float(self.flight_takeoff_hold_spin.value()),
                'land_key': self.flight_land_key_edit.text().strip() or 'p',
                'land_hold_sec': float(self.flight_land_hold_spin.value()),
            }
            self._save_main_config()

        skinning_wait_sec = None
        try:
            skinning_wait_sec = float(((self.config or {}).get('bot') or {}).get('skinning_wait_sec', 1.5))
        except Exception:
            skinning_wait_sec = 1.5
        try:
            food_key = str(((self.config or {}).get('bot') or {}).get('food_key', 'f1')).strip() or 'f1'
        except Exception:
            food_key = 'f1'
        try:
            eat_hp_threshold = int(((self.config or {}).get('bot') or {}).get('eat_hp_threshold', 30))
        except Exception:
            eat_hp_threshold = 30
        try:
            eat_max_wait_sec = float(((self.config or {}).get('bot') or {}).get('eat_max_wait_sec', 30.0))
        except Exception:
            eat_max_wait_sec = 30.0

        self.game_worker = GameLoopWorker(
            mode, waypoints_path=waypoints_path, vendor_path=vendor_path,
            attack_loop_path=attack_loop_path,
            skinning_wait_sec=skinning_wait_sec,
            food_key=food_key,
            eat_hp_threshold=eat_hp_threshold,
            eat_max_wait_sec=eat_max_wait_sec,
            quest_follow_follow_key=self.quest_follow_follow_edit.text(),
            quest_follow_interact_key=self.quest_follow_interact_edit.text(),
            quest_follow_interval_sec=self.quest_follow_interval_spin.value(),
            flight_json_path=flight_json_path,
            flight_takeoff_key=self.flight_takeoff_key_edit.text(),
            flight_takeoff_hold_sec=self.flight_takeoff_hold_spin.value(),
            flight_land_key=self.flight_land_key_edit.text(),
            flight_land_hold_sec=self.flight_land_hold_spin.value(),
        )
        self.game_worker.state_signal.connect(self.state_label.setText)
        self.game_worker.log_signal.connect(self.log)
        self.game_worker.stop_signal.connect(self._stop_game_worker)
        self.game_worker.finished.connect(self._on_game_loop_finished)
        self.game_worker.start()
        self.game_start_btn.setEnabled(False)
        self.game_stop_btn.setEnabled(True)
        mode_names = {
            'monitor': '状态监控',
            'patrol': '巡逻打怪',
            'combat': '自动打怪',
            'quest_follow': '任务跟随',
            'flight': '飞行模式',
            'return_repair': '回城修理',
        }
        self.status_bar.showMessage(f"🟢 {mode_names[mode]} 运行中")
        self.log(f"🎮 {mode_names[mode]} 已启动")

    def start_record(self):
        if self.game_worker and self.game_worker.isRunning():
            self.log("请先停止其他模式")
            return
        name = self.record_name_edit.text().strip() or "waypoints"
        min_dist = None
        try:
            min_dist = float(self.record_min_distance_spin.value())
        except Exception:
            min_dist = 0.5
        self.game_worker = GameLoopWorker(mode='record', record_filename=name, record_min_distance=min_dist)
        self.game_worker.state_signal.connect(self.record_state_label.setText)
        self.game_worker.log_signal.connect(self.log)
        self.game_worker.finished.connect(self._on_record_finished)
        self.game_worker.start()
        self.record_start_btn.setEnabled(False)
        self.record_stop_btn.setEnabled(True)
        self.record_save_btn.setEnabled(True)
        self.status_bar.showMessage("🟢 巡逻点录制运行中")
        self.log("🎮 巡逻点录制已启动")

    def _on_record_finished(self):
        self.record_start_btn.setEnabled(True)
        self.record_stop_btn.setEnabled(False)
        if self.game_worker and self.game_worker.recorder:
            self.pending_recorder = self.game_worker.recorder
        self.record_save_btn.setEnabled(bool(self.pending_recorder))
        self.game_worker = None

    def _on_game_loop_finished(self):
        self.game_start_btn.setEnabled(True)
        self.game_stop_btn.setEnabled(False)
        self.status_bar.showMessage("⏹️ 已停止")
        self.game_worker = None

    def stop_game_loop(self):
        self._stop_game_worker()

    def _stop_game_worker(self):
        """统一停止游戏循环，更新所有相关 UI"""
        if self.game_worker:
            self.game_worker.running = False
            # 额外释放一次移动按键，避免刚好卡在阻塞逻辑里导致按键滞留。
            try:
                if getattr(self.game_worker, "bot_move", None):
                    self.game_worker.bot_move.patrol_controller.stop_all()
                    self.game_worker.bot_move.logistics_manager.is_returning = False
                if getattr(self.game_worker, "patrol_controller", None):
                    self.game_worker.patrol_controller.stop_all()
                if getattr(self.game_worker, "logistics_manager", None):
                    self.game_worker.logistics_manager.is_returning = False
            except Exception:
                pass
            # 不在这里 wait()，避免阻塞 GUI；线程会在下一轮循环自然退出
        self.game_start_btn.setEnabled(True)
        self.game_stop_btn.setEnabled(False)
        self.record_start_btn.setEnabled(True)
        self.record_stop_btn.setEnabled(False)
        self.record_save_btn.setEnabled(bool(self.pending_recorder))
        self.state_label.setText("状态: ---")
        self.record_state_label.setText("状态: ---")
        self.status_bar.showMessage("⏹️ 已停止")
        self.log("🛑 已停止")

    def stop_record(self):
        self._stop_game_worker()

    def save_recorder(self):
        recorder = None
        if self.game_worker and self.game_worker.recorder:
            recorder = self.game_worker.recorder
        elif self.pending_recorder:
            recorder = self.pending_recorder
        if recorder:
            path = recorder.save()
            if path:
                self.log(f"✅ 路径已保存至 {path}")
                self.pending_recorder = None
                self.record_save_btn.setEnabled(False)
                QMessageBox.information(self, "保存成功", f"巡逻点已保存至:\n{path}")
        else:
            QMessageBox.warning(self, "提示", "请先进行巡逻点录制")

    def closeEvent(self, event):
        running = bool(self.key_workers) or (self.game_worker and self.game_worker.isRunning())
        if running:
            reply = QMessageBox.question(
                self, "退出", "尚有任务在运行，确认退出？",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No
            )
            if reply == QMessageBox.StandardButton.Yes:
                self.stop_keys()
                if self.game_worker:
                    self.game_worker.running = False
                    self.game_worker.wait()
                event.accept()
            else:
                event.ignore()
        else:
            event.accept()


def main():
    app = QApplication(sys.argv)
    window = WoWMultiKeyGUI()
    window.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
