from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
import json
import re
import tkinter as tk
from tkinter import messagebox, ttk

from ai_assistant import (
    AIConfigStore,
    AIError,
    DEFAULT_AI_BASE_URL,
    DEFAULT_AI_MODEL,
    DeepSeekClient,
)
from storage import NO_VPN, VPN_TYPES, ProjectStore, StorageError


MDM9_DEPLOY_DIR = "/u02/jars/"
MDM9_JARS = [
    "i-bpm-6.1.jar",
    "i-console-6.1.jar",
    "i-core-6.1.jar",
    "mdm-datacenter-6.1.jar",
    "mdm-engine-6.1.jar",
    "mdm-esb-6.1.jar",
    "mdm-ireport-6.1.jar",
    "mdm-mdmclean-6.1.jar",
    "mdm-mdmcode-6.1.jar",
    "mdm-mdmui-6.1.jar",
    "mdm-module-6.1.jar",
    "mdm-query-6.1.jar",
    "mdm-taskcenter-6.1.jar",
]

MDM_CLOUD_SERVICE_NAMES = [
    "admin",
    "auth",
    "base-service",
    "bi-dv-service",
    "file-service",
    "gateway",
    "gtx-service",
    "mdm-esb-service",
    "mdm-model-service-yapp",
    "mdm-quality-service",
    "xxl-job-service",
]

MDM_CLOUD_SERVICE_DIRS = {
    "admin": "admin",
    "auth": "auth",
    "base-service": "base",
    "bi-dv-service": "bi-dv",
    "file-service": "file",
    "gateway": "gateway",
    "gtx-service": "gtx",
    "mdm-esb-service": "mdm-esb",
    "mdm-model-service-yapp": "mdm-model",
    "mdm-quality-service": "mdm-quality",
    "xxl-job-service": "xxl-job",
}


def normalize_mdm_version(value: str) -> str:
    normalized = value.strip().upper().replace(" ", "").replace("-", "").replace("_", "")
    if normalized in {"9", "MDM9"} or re.search(r"(?:^|[^0-9.])(?:MDM)?9(?:\.\d+)*(?:$|[^0-9.])", normalized):
        return "MDM9"
    if normalized in {"12", "MDM12"} or re.search(r"(?:^|[^0-9])(?:MDM)?12(?:$|[^0-9])", normalized):
        return "MDM12"
    if normalized in {"13", "MDM13"} or re.search(r"(?:^|[^0-9])(?:MDM)?13(?:$|[^0-9])", normalized):
        return "MDM13"
    return normalized


def service_configs_for_mdm_version(value: str) -> list[dict[str, str]]:
    version = value.strip()
    normalized = normalize_mdm_version(version)
    if normalized == "MDM9":
        return [
            {
                "service_name": jar.removesuffix(".jar"),
                "jar_name": jar,
                "deploy_dir": MDM9_DEPLOY_DIR,
            }
            for jar in MDM9_JARS
        ]

    jar_version = cloud_jar_version(version)
    if jar_version is None:
        return []

    return [
        {
            "service_name": service_name,
            "jar_name": f"sunway-cloud-{service_name}-{jar_version}.jar",
            "deploy_dir": f"/home/mdm/{MDM_CLOUD_SERVICE_DIRS[service_name]}/",
        }
        for service_name in MDM_CLOUD_SERVICE_NAMES
    ]


def service_config_message(value: str) -> str:
    version = value.strip()
    normalized = normalize_mdm_version(version)
    if normalized in {"MDM12", "MDM13"}:
        return f"当前 MDM 版本「{value}」需要填写完整 jar 版本号，例如 mdm13.2.0.0。"
    if cloud_jar_version(version) is None:
        return f"当前 MDM 版本「{value}」还没有配置默认 Java 服务包。"
    return ""


def cloud_jar_version(value: str) -> str | None:
    version = value.strip().upper().replace(" ", "")
    match = re.search(r"(?:MDM)?((?:12|13)\.\d+(?:\.\d+){1,4})", version)
    if match:
        return match.group(1)
    return None


def join_remote_path(directory: str, filename: str) -> str:
    directory = directory.strip()
    if not directory:
        return filename
    return f"{directory.rstrip('/')}/{filename}"


def ensure_remote_directory(directory: str) -> str:
    directory = directory.strip()
    if not directory:
        return directory
    return directory if directory.endswith("/") else f"{directory}/"


def shell_quote(value: str) -> str:
    if value and all(char.isalnum() or char in "/._-:" for char in value):
        return value
    return "'" + value.replace("'", "'\\''") + "'"


def service_key_from_jar(jar_name: str) -> str:
    name = jar_name.strip()
    if name.startswith("sunway-cloud-"):
        name = name[len("sunway-cloud-") :]
    name = name.removesuffix(".jar")
    return re.sub(r"-(?:12|13)\.\d+(?:\.\d+){1,4}$", "", name)


@dataclass
class LogQuery:
    log_date: str
    start_time: str
    end_time: str


def build_log_extract_command(service_dir: str, query: LogQuery) -> str:
    service_dir = service_dir.strip().rstrip("/")
    if not service_dir:
        raise ValueError("服务目录不能为空。")
    if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", query.log_date):
        raise ValueError("日志日期格式应为 YYYY-MM-DD。")
    if not re.fullmatch(r"\d{2}:\d{2}:\d{2}", query.start_time):
        raise ValueError("开始时间格式应为 HH:MM:SS。")
    if not re.fullmatch(r"\d{2}:\d{2}:\d{2}", query.end_time):
        raise ValueError("结束时间格式应为 HH:MM:SS。")

    output_name = f"{log_output_prefix(service_dir)}{query.log_date[5:7]}{query.log_date[8:10]}-1.log"
    output_path = f"{service_dir}/{output_name}"
    today = datetime.now().strftime("%Y-%m-%d")
    if query.log_date == today:
        log_path = f"{service_dir}/log/app.log"
        return (
            f"sed -n '/{query.start_time}/,/{query.end_time}/p' "
            f"{shell_quote(log_path)} > {shell_quote(output_path)}"
        )

    month = query.log_date[:7]
    log_path = f"{service_dir}/log/{month}/{query.log_date}-app.log-1.log.gz"
    return (
        f"zcat {shell_quote(log_path)} | "
        f"sed -n '/{query.start_time}/,/{query.end_time}/p' > {shell_quote(output_path)}"
    )


def log_output_prefix(service_dir: str) -> str:
    service_name = service_dir.strip().rstrip("/").split("/")[-1]
    if service_name.startswith("mdm-"):
        service_name = service_name[4:]
    if service_name.endswith("-service"):
        service_name = service_name[: -len("-service")]
    return re.sub(r"[^A-Za-z0-9]+", "", service_name) or "log"


def center_window(window: tk.Toplevel | tk.Tk, parent: tk.Toplevel | tk.Tk | None = None) -> None:
    window.update_idletasks()
    width = window.winfo_width()
    height = window.winfo_height()
    if parent is not None:
        parent.update_idletasks()
        x = parent.winfo_rootx() + (parent.winfo_width() - width) // 2
        y = parent.winfo_rooty() + (parent.winfo_height() - height) // 2
    else:
        x = (window.winfo_screenwidth() - width) // 2
        y = (window.winfo_screenheight() - height) // 2
    window.geometry(f"+{max(x, 0)}+{max(y, 0)}")


def activate_modal(dialog: tk.Toplevel, parent: tk.Tk, focus_widget: tk.Widget) -> None:
    dialog.transient(parent)
    dialog.update_idletasks()
    center_window(dialog, parent)
    dialog.lift(parent)
    dialog.attributes("-topmost", True)
    dialog.after(200, lambda: dialog.attributes("-topmost", False))
    dialog.grab_set()
    focus_widget.focus_force()

    def restore_dialog(_event: tk.Event | None = None) -> None:
        if not dialog.winfo_exists():
            return
        dialog.deiconify()
        dialog.lift(parent)
        dialog.focus_force()

    parent.bind("<Map>", restore_dialog, add="+")
    dialog.bind("<FocusOut>", lambda _event: dialog.after(50, restore_dialog), add="+")


def close_modal(dialog: tk.Toplevel) -> None:
    try:
        dialog.grab_release()
    except tk.TclError:
        pass
    dialog.destroy()


class ProjectDialog(tk.Toplevel):
    def __init__(
        self,
        master: tk.Tk,
        title: str,
        on_save,
        project: dict[str, str] | None = None,
    ) -> None:
        super().__init__(master)
        self.title(title)
        self.resizable(False, False)

        self.on_save = on_save
        self.project = project
        self.show_password = tk.BooleanVar(value=False)
        self.project_name = tk.StringVar(value=project.get("project_name", "") if project else "")
        self.vpn_type = tk.StringVar(value=project.get("vpn_type", VPN_TYPES[0]) if project else VPN_TYPES[0])
        self.vpn_user = tk.StringVar(value=project.get("vpn_user", "") if project else "")
        self.vpn_password = tk.StringVar(value=project.get("vpn_password", "") if project else "")
        self.mdm_version = tk.StringVar(value=project.get("mdm_version", "") if project else "")

        self._build()
        self._sync_vpn_state()
        self.protocol("WM_DELETE_WINDOW", lambda: close_modal(self))
        self.bind("<Return>", lambda _event: self._save())
        self.bind("<Escape>", lambda _event: close_modal(self))
        activate_modal(self, master, self.name_entry)

    def _build(self) -> None:
        frame = ttk.Frame(self, padding=16)
        frame.grid(row=0, column=0, sticky="nsew")

        ttk.Label(frame, text="项目名称").grid(row=0, column=0, sticky="w", pady=6)
        self.name_entry = ttk.Entry(
            frame, textvariable=self.project_name, width=36, justify="left"
        )
        self.name_entry.grid(row=0, column=1, sticky="ew", pady=6)

        ttk.Label(frame, text="VPN登录方式").grid(row=1, column=0, sticky="w", pady=6)
        self.vpn_combo = ttk.Combobox(
            frame,
            textvariable=self.vpn_type,
            values=VPN_TYPES,
            state="readonly",
            width=33,
        )
        self.vpn_combo.grid(row=1, column=1, sticky="ew", pady=6)
        self.vpn_combo.bind("<<ComboboxSelected>>", lambda _event: self._sync_vpn_state())

        ttk.Label(frame, text="VPN账号").grid(row=2, column=0, sticky="w", pady=6)
        self.user_entry = ttk.Entry(
            frame, textvariable=self.vpn_user, width=36, justify="left"
        )
        self.user_entry.grid(row=2, column=1, sticky="ew", pady=6)

        ttk.Label(frame, text="VPN密码").grid(row=3, column=0, sticky="w", pady=6)
        self.password_entry = ttk.Entry(
            frame, textvariable=self.vpn_password, show="*", width=36, justify="left"
        )
        self.password_entry.grid(row=3, column=1, sticky="ew", pady=6)

        self.show_password_check = ttk.Checkbutton(
            frame,
            text="显示密码",
            variable=self.show_password,
            command=self._sync_password_visibility,
        )
        self.show_password_check.grid(row=4, column=1, sticky="w", pady=(0, 6))

        ttk.Label(frame, text="MDM版本号").grid(row=5, column=0, sticky="w", pady=6)
        self.mdm_entry = ttk.Entry(
            frame, textvariable=self.mdm_version, width=36, justify="left"
        )
        self.mdm_entry.grid(row=5, column=1, sticky="ew", pady=6)

        buttons = ttk.Frame(frame)
        buttons.grid(row=6, column=0, columnspan=2, sticky="e", pady=(14, 0))
        ttk.Button(buttons, text="保存", command=self._save).grid(row=0, column=0, padx=(0, 8))
        ttk.Button(buttons, text="取消", command=self.destroy).grid(row=0, column=1)

    def _sync_vpn_state(self) -> None:
        disabled = self.vpn_type.get() == NO_VPN
        state = "disabled" if disabled else "normal"
        self.user_entry.configure(state=state)
        self.password_entry.configure(state=state)
        self.show_password_check.configure(state=state)
        if disabled:
            self.vpn_user.set("")
            self.vpn_password.set("")
        self._sync_password_visibility()

    def _sync_password_visibility(self) -> None:
        if self.vpn_type.get() == NO_VPN:
            self.password_entry.configure(show="*")
        else:
            self.password_entry.configure(show="" if self.show_password.get() else "*")

    def _save(self) -> None:
        try:
            self.on_save(
                self.project_name.get(),
                self.vpn_type.get(),
                self.mdm_version.get(),
                self.vpn_user.get(),
                self.vpn_password.get(),
            )
        except StorageError as exc:
            messagebox.showerror("保存失败", str(exc), parent=self)
            return
        close_modal(self)


class ServiceEditDialog(tk.Toplevel):
    def __init__(
        self,
        master: tk.Tk,
        title: str,
        service: dict[str, str],
        on_save,
    ) -> None:
        super().__init__(master)
        self.title(title)
        self.resizable(False, False)

        self.on_save = on_save
        self.service_name = tk.StringVar(value=service.get("service_name", ""))
        self.jar_name = tk.StringVar(value=service.get("jar_name", ""))
        self.deploy_dir = tk.StringVar(value=service.get("deploy_dir", ""))

        self._build()
        self.protocol("WM_DELETE_WINDOW", lambda: close_modal(self))
        self.bind("<Return>", lambda _event: self._save())
        self.bind("<Escape>", lambda _event: close_modal(self))
        activate_modal(self, master, self.jar_entry)

    def _build(self) -> None:
        frame = ttk.Frame(self, padding=16)
        frame.grid(row=0, column=0, sticky="nsew")

        ttk.Label(frame, text="服务名").grid(row=0, column=0, sticky="w", pady=6)
        ttk.Entry(frame, textvariable=self.service_name, width=48).grid(
            row=0, column=1, sticky="ew", pady=6
        )

        ttk.Label(frame, text="jar文件名").grid(row=1, column=0, sticky="w", pady=6)
        self.jar_entry = ttk.Entry(frame, textvariable=self.jar_name, width=48)
        self.jar_entry.grid(row=1, column=1, sticky="ew", pady=6)

        ttk.Label(frame, text="部署目录").grid(row=2, column=0, sticky="w", pady=6)
        ttk.Entry(frame, textvariable=self.deploy_dir, width=48).grid(
            row=2, column=1, sticky="ew", pady=6
        )

        buttons = ttk.Frame(frame)
        buttons.grid(row=3, column=0, columnspan=2, sticky="e", pady=(14, 0))
        ttk.Button(buttons, text="保存", command=self._save).grid(row=0, column=0, padx=(0, 8))
        ttk.Button(buttons, text="取消", command=self.destroy).grid(row=0, column=1)

    def _save(self) -> None:
        service_name = self.service_name.get().strip()
        jar_name = self.jar_name.get().strip()
        deploy_dir = self.deploy_dir.get().strip()
        if not service_name:
            messagebox.showerror("保存失败", "服务名不能为空。", parent=self)
            return
        if not jar_name:
            messagebox.showerror("保存失败", "jar文件名不能为空。", parent=self)
            return
        if not deploy_dir:
            messagebox.showerror("保存失败", "部署目录不能为空。", parent=self)
            return
        self.on_save(
            {
                "service_name": service_name,
                "jar_name": jar_name,
                "deploy_dir": ensure_remote_directory(deploy_dir),
            }
        )
        close_modal(self)


class ProjectApp(tk.Tk):
    def __init__(self) -> None:
        super().__init__()
        self.title("运维项目管理")
        self.geometry("1240x760")
        self.minsize(1080, 640)
        self._configure_window_icon()

        self.store = ProjectStore()
        self.ai_config_store = AIConfigStore()
        self.ai_config = self._load_ai_config()
        self.search_text = tk.StringVar()
        self.projects: list[dict[str, str]] = []
        self.pages: dict[str, ttk.Frame] = {}
        self.nav_buttons: dict[str, tk.Button] = {}
        self.backup_services: list[dict[str, str]] = []
        self.migration_services: list[dict[str, str]] = []
        self.log_services: list[dict[str, str]] = []
        self.ai_project_options: dict[str, str] = {}
        self.log_project_options: dict[str, str] = {}
        self.ai_result: dict[str, object] | None = None

        self._build()
        self.refresh_projects()

    def _configure_window_icon(self) -> None:
        try:
            icon = tk.PhotoImage(width=16, height=16)
            icon.put("#2563eb", to=(3, 2, 13, 14))
            icon.put("#ffffff", to=(5, 4, 11, 6))
            icon.put("#ffffff", to=(5, 8, 11, 10))
            icon.put("#ffffff", to=(5, 12, 11, 13))
            self.iconphoto(True, icon)
            self._window_icon = icon
        except tk.TclError:
            pass

    def _load_ai_config(self) -> dict[str, str]:
        try:
            return self.ai_config_store.load()
        except AIError as exc:
            messagebox.showerror("AI配置读取失败", str(exc), parent=self)
            return {
                "base_url": DEFAULT_AI_BASE_URL,
                "model": DEFAULT_AI_MODEL,
                "api_key": "",
            }

    def _build(self) -> None:
        self.configure(bg="#f4f7fb")
        self.columnconfigure(1, weight=1)
        self.rowconfigure(0, weight=1)

        style = ttk.Style(self)
        style.configure("App.TFrame", background="#f4f7fb")
        style.configure("Panel.TFrame", background="#ffffff")
        style.configure("Title.TLabel", background="#f4f7fb", foreground="#0f172a", font=("Microsoft YaHei UI", 18, "bold"))
        style.configure("Hint.TLabel", background="#ffffff", foreground="#6b7280")
        style.configure("Section.TLabelframe", background="#ffffff", bordercolor="#e5e7eb")
        style.configure("Section.TLabelframe.Label", background="#ffffff", foreground="#334155", font=("Microsoft YaHei UI", 10, "bold"))

        self.sidebar = tk.Frame(self, bg="#fbfdff", width=196, highlightthickness=1, highlightbackground="#e2e8f0")
        self.sidebar.grid(row=0, column=0, sticky="ns")
        self.sidebar.grid_propagate(False)

        tk.Label(
            self.sidebar,
            text="运维管理",
            bg="#fbfdff",
            fg="#0f172a",
            font=("Microsoft YaHei UI", 16, "bold"),
            anchor="w",
            padx=18,
            pady=18,
        ).grid(row=0, column=0, sticky="ew")

        tk.Label(
            self.sidebar,
            text="Operations",
            bg="#fbfdff",
            fg="#94a3b8",
            font=("Segoe UI", 9),
            anchor="w",
            padx=18,
            pady=0,
        ).grid(row=1, column=0, sticky="ew")

        self.nav_container = tk.Frame(self.sidebar, bg="#fbfdff")
        self.nav_container.grid(row=2, column=0, sticky="new", padx=10, pady=(18, 0))
        self.sidebar.rowconfigure(3, weight=1)

        self.sidebar_info = tk.Frame(self.sidebar, bg="#f8fafc", highlightthickness=1, highlightbackground="#e2e8f0")
        self.sidebar_info.grid(row=3, column=0, sticky="sew", padx=12, pady=(16, 14))
        tk.Label(
            self.sidebar_info,
            text="运维提醒",
            bg="#f8fafc",
            fg="#0f172a",
            font=("Microsoft YaHei UI", 10, "bold"),
            anchor="w",
            padx=12,
            pady=8,
        ).grid(row=0, column=0, sticky="ew")
        tk.Label(
            self.sidebar_info,
            text="迁移前先备份\n命令只生成不执行\n支持 MDM9/12/13",
            bg="#f8fafc",
            fg="#64748b",
            font=("Microsoft YaHei UI", 9),
            justify="left",
            anchor="w",
            padx=12,
            pady=8,
        ).grid(row=1, column=0, sticky="ew")

        self.content = ttk.Frame(self, style="App.TFrame", padding=24)
        self.content.grid(row=0, column=1, sticky="nsew")
        self.content.columnconfigure(0, weight=1)
        self.content.rowconfigure(1, weight=1)

        self.page_title = ttk.Label(self.content, style="Title.TLabel")
        self.page_title.grid(row=0, column=0, sticky="ew", pady=(0, 12))

        self.page_area = ttk.Frame(self.content, style="App.TFrame")
        self.page_area.grid(row=1, column=0, sticky="nsew")
        self.page_area.columnconfigure(0, weight=1)
        self.page_area.rowconfigure(0, weight=1)

        self.project_tab = ttk.Frame(self.page_area, style="Panel.TFrame")
        self.backup_tab = ttk.Frame(self.page_area, style="Panel.TFrame")
        self.migration_tab = ttk.Frame(self.page_area, style="Panel.TFrame")
        self.ai_tab = ttk.Frame(self.page_area, style="Panel.TFrame")
        self.log_tab = ttk.Frame(self.page_area, style="Panel.TFrame")
        self.pages = {
            "projects": self.project_tab,
            "backup": self.backup_tab,
            "migration": self.migration_tab,
            "ai": self.ai_tab,
            "logs": self.log_tab,
        }
        for page in self.pages.values():
            page.grid(row=0, column=0, sticky="nsew")

        self._add_nav_button("projects", "项目管理")
        self._add_nav_button("backup", "备份命令")
        self._add_nav_button("migration", "文件迁移")
        self._add_nav_button("ai", "AI服务识别")
        self._add_nav_button("logs", "日志截取")

        self._build_project_tab()
        self._build_backup_tab()
        self._build_migration_tab()
        self._build_ai_tab()
        self._build_log_tab()
        self.show_page("projects")

    def _add_nav_button(self, page_name: str, text: str) -> None:
        button = tk.Button(
            self.nav_container,
            text=text,
            command=lambda: self.show_page(page_name),
            anchor="w",
            bd=0,
            cursor="hand2",
            font=("Microsoft YaHei UI", 10, "bold"),
            padx=16,
            pady=11,
            relief="flat",
            highlightthickness=0,
        )
        button.grid(row=len(self.nav_buttons), column=0, sticky="ew", pady=4)
        self.nav_container.columnconfigure(0, weight=1)
        self.nav_buttons[page_name] = button

    def show_page(self, page_name: str) -> None:
        titles = {
            "projects": "项目管理",
            "backup": "备份命令生成",
            "migration": "文件迁移命令生成",
            "ai": "AI服务识别",
            "logs": "日志截取命令生成",
        }
        self.page_title.configure(text=titles[page_name])
        self.pages[page_name].tkraise()
        for name, button in self.nav_buttons.items():
            if name == page_name:
                button.configure(bg="#eaf2ff", fg="#1d4ed8", activebackground="#dbeafe")
            else:
                button.configure(bg="#fbfdff", fg="#475569", activebackground="#f1f5f9")

    def _build_project_tab(self) -> None:
        self.project_tab.columnconfigure(0, weight=1)
        self.project_tab.rowconfigure(1, weight=1)

        toolbar = ttk.Frame(self.project_tab, padding=(12, 12, 12, 6))
        toolbar.grid(row=0, column=0, sticky="ew")
        toolbar.columnconfigure(1, weight=1)

        ttk.Label(toolbar, text="搜索").grid(row=0, column=0, padx=(0, 8))
        search_entry = ttk.Entry(toolbar, textvariable=self.search_text)
        search_entry.grid(row=0, column=1, sticky="ew", padx=(0, 10))
        search_entry.bind("<KeyRelease>", lambda _event: self._render_projects())

        ttk.Button(toolbar, text="新增", command=self.add_project).grid(row=0, column=2, padx=4)
        ttk.Button(toolbar, text="编辑", command=self.edit_project).grid(row=0, column=3, padx=4)
        ttk.Button(toolbar, text="删除", command=self.delete_project).grid(row=0, column=4, padx=4)
        ttk.Button(toolbar, text="刷新", command=self.refresh_projects).grid(row=0, column=5, padx=(4, 0))

        table_frame = ttk.Frame(self.project_tab, padding=(12, 6, 12, 6))
        table_frame.grid(row=1, column=0, sticky="nsew")
        table_frame.columnconfigure(0, weight=1)
        table_frame.rowconfigure(0, weight=1)

        columns = ("project_name", "vpn_type", "vpn_user", "vpn_password", "mdm_version")
        self.tree = ttk.Treeview(
            table_frame,
            columns=columns,
            show="headings",
            selectmode="browse",
        )
        self.tree.heading("project_name", text="项目名称", anchor="center")
        self.tree.heading("vpn_type", text="VPN登录方式", anchor="center")
        self.tree.heading("vpn_user", text="VPN账号", anchor="center")
        self.tree.heading("vpn_password", text="VPN密码", anchor="center")
        self.tree.heading("mdm_version", text="MDM版本号", anchor="center")

        self.tree.column("project_name", width=230, minwidth=160, anchor="center")
        self.tree.column("vpn_type", width=190, minwidth=150, anchor="center")
        self.tree.column("vpn_user", width=160, minwidth=120, anchor="center")
        self.tree.column("vpn_password", width=130, minwidth=90, anchor="center")
        self.tree.column("mdm_version", width=150, minwidth=110, anchor="center")

        scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=scrollbar.set)
        self.tree.grid(row=0, column=0, sticky="nsew")
        scrollbar.grid(row=0, column=1, sticky="ns")
        self.tree.bind("<Double-1>", lambda _event: self.edit_project())

        self.status = ttk.Label(self.project_tab, padding=(12, 6, 12, 12), anchor="w")
        self.status.grid(row=2, column=0, sticky="ew")

    def _build_backup_tab(self) -> None:
        self.backup_tab.columnconfigure(0, weight=1)
        self.backup_tab.rowconfigure(1, weight=1)
        self.backup_tab.rowconfigure(3, weight=1)

        top = ttk.Frame(self.backup_tab, padding=(12, 12, 12, 6))
        top.grid(row=0, column=0, sticky="ew")
        top.columnconfigure(1, weight=1)

        self.backup_project = tk.StringVar()
        self.backup_mdm_version = tk.StringVar(value="MDM版本号：")
        self.backup_project_options: dict[str, str] = {}

        ttk.Label(top, text="项目").grid(row=0, column=0, padx=(0, 8))
        self.backup_project_combo = ttk.Combobox(
            top,
            textvariable=self.backup_project,
            state="readonly",
            width=42,
        )
        self.backup_project_combo.grid(row=0, column=1, sticky="ew", padx=(0, 12))
        self.backup_project_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_backup_services())

        ttk.Label(top, textvariable=self.backup_mdm_version).grid(
            row=0, column=2, sticky="w", padx=(0, 12)
        )
        ttk.Button(top, text="刷新项目", command=self.refresh_projects).grid(row=0, column=3)

        service_frame = ttk.LabelFrame(self.backup_tab, text="Java服务包", padding=12, style="Section.TLabelframe")
        service_frame.grid(row=1, column=0, sticky="nsew", padx=12, pady=6)
        service_frame.columnconfigure(0, weight=1)
        service_frame.rowconfigure(0, weight=1)

        self.service_tree = ttk.Treeview(
            service_frame,
            columns=("service_name", "jar_name", "deploy_dir"),
            show="headings",
            selectmode="extended",
        )
        self.service_tree.heading("service_name", text="服务名", anchor="center")
        self.service_tree.heading("jar_name", text="jar文件名", anchor="center")
        self.service_tree.heading("deploy_dir", text="部署目录", anchor="center")
        self.service_tree.column("service_name", width=190, minwidth=120, anchor="center")
        self.service_tree.column("jar_name", width=360, minwidth=240, anchor="center")
        self.service_tree.column("deploy_dir", width=220, minwidth=160, anchor="center")
        service_scrollbar = ttk.Scrollbar(
            service_frame, orient="vertical", command=self.service_tree.yview
        )
        self.service_tree.configure(yscrollcommand=service_scrollbar.set)
        self.service_tree.grid(row=0, column=0, sticky="nsew")
        service_scrollbar.grid(row=0, column=1, sticky="ns")
        self.service_tree.bind("<Double-1>", lambda _event: self.edit_backup_service())

        actions = ttk.Frame(self.backup_tab, padding=(12, 4, 12, 4))
        actions.grid(row=2, column=0, sticky="ew")
        ttk.Button(actions, text="全选", command=self.select_all_services).grid(row=0, column=0, padx=(0, 8))
        ttk.Button(actions, text="清空选择", command=self.clear_service_selection).grid(row=0, column=1, padx=(0, 8))
        ttk.Button(actions, text="编辑服务", command=self.edit_backup_service).grid(row=0, column=2, padx=(0, 8))
        ttk.Button(actions, text="生成命令", command=self.generate_backup_commands).grid(row=0, column=3, padx=(0, 8))
        ttk.Button(actions, text="复制命令", command=self.copy_backup_commands).grid(row=0, column=4)

        output_frame = ttk.LabelFrame(self.backup_tab, text="备份命令", padding=12, style="Section.TLabelframe")
        output_frame.grid(row=3, column=0, sticky="nsew", padx=12, pady=(6, 12))
        output_frame.columnconfigure(0, weight=1)
        output_frame.rowconfigure(0, weight=1)

        self.command_output = tk.Text(output_frame, height=8, wrap="none")
        output_scrollbar = ttk.Scrollbar(
            output_frame, orient="vertical", command=self.command_output.yview
        )
        self.command_output.configure(yscrollcommand=output_scrollbar.set)
        self.command_output.grid(row=0, column=0, sticky="nsew")
        output_scrollbar.grid(row=0, column=1, sticky="ns")

    def _build_migration_tab(self) -> None:
        self.migration_tab.columnconfigure(0, weight=1)
        self.migration_tab.rowconfigure(2, weight=1)
        self.migration_tab.rowconfigure(4, weight=1)

        top = ttk.Frame(self.migration_tab, padding=(12, 12, 12, 6))
        top.grid(row=0, column=0, sticky="ew")
        top.columnconfigure(1, weight=1)

        self.migration_project = tk.StringVar()
        self.migration_mdm_version = tk.StringVar(value="MDM版本号：")
        self.migration_project_options: dict[str, str] = {}

        ttk.Label(top, text="项目").grid(row=0, column=0, padx=(0, 8))
        self.migration_project_combo = ttk.Combobox(
            top,
            textvariable=self.migration_project,
            state="readonly",
            width=42,
        )
        self.migration_project_combo.grid(row=0, column=1, sticky="ew", padx=(0, 12))
        self.migration_project_combo.bind(
            "<<ComboboxSelected>>", lambda _event: self._load_migration_services()
        )
        ttk.Label(top, textvariable=self.migration_mdm_version).grid(
            row=0, column=2, sticky="w", padx=(0, 12)
        )
        ttk.Button(top, text="刷新项目", command=self.refresh_projects).grid(row=0, column=3)

        path_frame = ttk.LabelFrame(self.migration_tab, text="目录配置", padding=12, style="Section.TLabelframe")
        path_frame.grid(row=1, column=0, sticky="ew", padx=12, pady=6)
        path_frame.columnconfigure(1, weight=1)

        self.source_dir = tk.StringVar(value="/A")
        ttk.Label(path_frame, text="A下载目录").grid(row=0, column=0, sticky="w", padx=(0, 8))
        ttk.Entry(path_frame, textvariable=self.source_dir).grid(
            row=0, column=1, sticky="ew"
        )
        ttk.Label(
            path_frame,
            text="目标目录使用下方每个服务的部署目录，可双击服务临时修改。",
            style="Hint.TLabel",
        ).grid(
            row=1, column=0, columnspan=2, sticky="w", pady=(8, 0)
        )

        service_frame = ttk.LabelFrame(self.migration_tab, text="需要迁移的服务包", padding=12, style="Section.TLabelframe")
        service_frame.grid(row=2, column=0, sticky="nsew", padx=12, pady=6)
        service_frame.columnconfigure(0, weight=1)
        service_frame.rowconfigure(0, weight=1)

        self.migration_service_tree = ttk.Treeview(
            service_frame,
            columns=("service_name", "jar_name", "deploy_dir"),
            show="headings",
            selectmode="extended",
        )
        self.migration_service_tree.heading("service_name", text="服务名", anchor="center")
        self.migration_service_tree.heading("jar_name", text="jar文件名", anchor="center")
        self.migration_service_tree.heading("deploy_dir", text="部署目录", anchor="center")
        self.migration_service_tree.column("service_name", width=190, minwidth=120, anchor="center")
        self.migration_service_tree.column("jar_name", width=360, minwidth=240, anchor="center")
        self.migration_service_tree.column("deploy_dir", width=220, minwidth=160, anchor="center")
        migration_service_scrollbar = ttk.Scrollbar(
            service_frame, orient="vertical", command=self.migration_service_tree.yview
        )
        self.migration_service_tree.configure(yscrollcommand=migration_service_scrollbar.set)
        self.migration_service_tree.grid(row=0, column=0, sticky="nsew")
        migration_service_scrollbar.grid(row=0, column=1, sticky="ns")
        self.migration_service_tree.bind(
            "<Double-1>", lambda _event: self.edit_migration_service()
        )

        actions = ttk.Frame(self.migration_tab, padding=(12, 4, 12, 4))
        actions.grid(row=3, column=0, sticky="ew")
        ttk.Button(actions, text="全选", command=self.select_all_migration_services).grid(
            row=0, column=0, padx=(0, 8)
        )
        ttk.Button(actions, text="清空选择", command=self.clear_migration_service_selection).grid(
            row=0, column=1, padx=(0, 8)
        )
        ttk.Button(actions, text="编辑服务", command=self.edit_migration_service).grid(
            row=0, column=2, padx=(0, 8)
        )
        ttk.Button(actions, text="生成命令", command=self.generate_migration_commands).grid(
            row=0, column=3, padx=(0, 8)
        )
        ttk.Button(actions, text="复制命令", command=self.copy_migration_commands).grid(
            row=0, column=4
        )

        output_frame = ttk.LabelFrame(self.migration_tab, text="迁移命令", padding=12, style="Section.TLabelframe")
        output_frame.grid(row=4, column=0, sticky="nsew", padx=12, pady=(6, 12))
        output_frame.columnconfigure(0, weight=1)
        output_frame.rowconfigure(0, weight=1)

        self.migration_command_output = tk.Text(output_frame, height=8, wrap="none")
        migration_output_scrollbar = ttk.Scrollbar(
            output_frame, orient="vertical", command=self.migration_command_output.yview
        )
        self.migration_command_output.configure(yscrollcommand=migration_output_scrollbar.set)
        self.migration_command_output.grid(row=0, column=0, sticky="nsew")
        migration_output_scrollbar.grid(row=0, column=1, sticky="ns")

    def _build_ai_tab(self) -> None:
        self.ai_tab.columnconfigure(0, weight=1)
        self.ai_tab.rowconfigure(1, weight=1)
        self.ai_tab.rowconfigure(3, weight=2)
        self.ai_tab.rowconfigure(4, weight=1)

        config_frame = ttk.LabelFrame(self.ai_tab, text="AI接口配置", padding=12, style="Section.TLabelframe")
        config_frame.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 6))
        config_frame.columnconfigure(1, weight=1)
        config_frame.columnconfigure(3, weight=1)

        self.ai_base_url = tk.StringVar(value=self.ai_config.get("base_url", DEFAULT_AI_BASE_URL))
        self.ai_model = tk.StringVar(value=self.ai_config.get("model", DEFAULT_AI_MODEL))
        self.ai_api_key = tk.StringVar(value=self.ai_config.get("api_key", ""))
        self.ai_project = tk.StringVar()

        ttk.Label(config_frame, text="API URL").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4)
        ttk.Entry(config_frame, textvariable=self.ai_base_url).grid(
            row=0, column=1, sticky="ew", padx=(0, 12), pady=4
        )
        ttk.Label(config_frame, text="模型").grid(row=0, column=2, sticky="w", padx=(0, 8), pady=4)
        ttk.Entry(config_frame, textvariable=self.ai_model).grid(
            row=0, column=3, sticky="ew", padx=(0, 12), pady=4
        )
        ttk.Label(config_frame, text="API Key").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4)
        ttk.Entry(config_frame, textvariable=self.ai_api_key, show="*").grid(
            row=1, column=1, sticky="ew", padx=(0, 12), pady=4
        )
        ttk.Label(config_frame, text="项目").grid(row=1, column=2, sticky="w", padx=(0, 8), pady=4)
        self.ai_project_combo = ttk.Combobox(
            config_frame,
            textvariable=self.ai_project,
            state="readonly",
        )
        self.ai_project_combo.grid(row=1, column=3, sticky="ew", padx=(0, 12), pady=4)
        ttk.Button(config_frame, text="保存配置", command=self.save_ai_config).grid(
            row=0, column=4, rowspan=2, sticky="ns"
        )

        input_frame = ttk.LabelFrame(
            self.ai_tab,
            text="粘贴服务器 jar 列表或目录列表（MDM12/13 可使用 ps -ef | grep sunway 获取服务列表）",
            padding=12,
            style="Section.TLabelframe",
        )
        input_frame.grid(row=1, column=0, sticky="nsew", padx=12, pady=6)
        input_frame.columnconfigure(0, weight=1)
        input_frame.rowconfigure(0, weight=1)

        self.ai_input = tk.Text(input_frame, height=8, wrap="none")
        ai_input_scrollbar = ttk.Scrollbar(input_frame, orient="vertical", command=self.ai_input.yview)
        self.ai_input.configure(yscrollcommand=ai_input_scrollbar.set)
        self.ai_input.grid(row=0, column=0, sticky="nsew")
        ai_input_scrollbar.grid(row=0, column=1, sticky="ns")
        ttk.Label(
            input_frame,
            text="提示：MDM12/13 建议在服务器执行 ps -ef | grep sunway，将包含 -jar 绝对路径的输出粘贴到这里。",
            style="Hint.TLabel",
        ).grid(row=1, column=0, sticky="w", pady=(8, 0))
        ttk.Button(input_frame, text="开始AI识别", command=self.run_ai_service_analysis).grid(
            row=1, column=0, sticky="e", pady=(8, 0), padx=(0, 112)
        )
        ttk.Button(
            input_frame,
            text="应用识别目录",
            command=self.apply_ai_services_to_command_pages,
        ).grid(
            row=1, column=0, sticky="e", pady=(8, 0)
        )

        actions = ttk.Frame(self.ai_tab, padding=(12, 4, 12, 4))
        actions.grid(row=2, column=0, sticky="ew")
        ttk.Button(actions, text="复制JSON结果", command=self.copy_ai_result).grid(
            row=0, column=0, padx=(0, 8)
        )
        ttk.Button(actions, text="清空", command=self.clear_ai_page).grid(row=0, column=1)

        result_frame = ttk.LabelFrame(self.ai_tab, text="识别结果", padding=12, style="Section.TLabelframe")
        result_frame.grid(row=3, column=0, sticky="nsew", padx=12, pady=6)
        result_frame.columnconfigure(0, weight=1)
        result_frame.rowconfigure(1, weight=1)

        self.ai_summary = ttk.Label(result_frame, text="尚未识别。", anchor="w")
        self.ai_summary.grid(row=0, column=0, sticky="ew", pady=(0, 8))

        self.ai_service_tree = ttk.Treeview(
            result_frame,
            columns=("service_name", "jar_name", "jar_version", "deploy_dir", "is_custom", "risk"),
            show="headings",
            selectmode="browse",
        )
        headings = {
            "service_name": "服务名",
            "jar_name": "jar文件名",
            "jar_version": "版本号",
            "deploy_dir": "部署目录",
            "is_custom": "定制",
            "risk": "风险",
        }
        widths = {
            "service_name": 170,
            "jar_name": 330,
            "jar_version": 110,
            "deploy_dir": 220,
            "is_custom": 70,
            "risk": 240,
        }
        for column, heading in headings.items():
            self.ai_service_tree.heading(column, text=heading, anchor="center")
            self.ai_service_tree.column(
                column, width=widths[column], minwidth=60, anchor="center"
            )
        ai_result_scrollbar = ttk.Scrollbar(
            result_frame, orient="vertical", command=self.ai_service_tree.yview
        )
        self.ai_service_tree.configure(yscrollcommand=ai_result_scrollbar.set)
        self.ai_service_tree.grid(row=1, column=0, sticky="nsew")
        ai_result_scrollbar.grid(row=1, column=1, sticky="ns")

        risk_frame = ttk.LabelFrame(self.ai_tab, text="缺失/定制/风险提示", padding=12, style="Section.TLabelframe")
        risk_frame.grid(row=4, column=0, sticky="nsew", padx=12, pady=(6, 12))
        risk_frame.columnconfigure(0, weight=1)
        risk_frame.rowconfigure(0, weight=1)

        self.ai_risk_output = tk.Text(risk_frame, height=10, wrap="none")
        risk_scrollbar = ttk.Scrollbar(risk_frame, orient="vertical", command=self.ai_risk_output.yview)
        risk_x_scrollbar = ttk.Scrollbar(
            risk_frame, orient="horizontal", command=self.ai_risk_output.xview
        )
        self.ai_risk_output.configure(
            yscrollcommand=risk_scrollbar.set,
            xscrollcommand=risk_x_scrollbar.set,
        )
        self.ai_risk_output.grid(row=0, column=0, sticky="nsew")
        risk_scrollbar.grid(row=0, column=1, sticky="ns")
        risk_x_scrollbar.grid(row=1, column=0, sticky="ew")

    def _build_log_tab(self) -> None:
        self.log_tab.columnconfigure(0, weight=1)
        self.log_tab.rowconfigure(2, weight=1)
        self.log_tab.rowconfigure(4, weight=1)

        top = ttk.Frame(self.log_tab, padding=(12, 12, 12, 6))
        top.grid(row=0, column=0, sticky="ew")
        top.columnconfigure(1, weight=1)

        self.log_project = tk.StringVar()
        self.log_service = tk.StringVar()
        self.log_project_mdm = tk.StringVar(value="MDM版本号：")
        self.log_date = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d"))
        self.log_start_time = tk.StringVar(value="00:00:00")
        self.log_end_time = tk.StringVar(value="00:10:00")
        self.log_ai_placeholder = "例如：今天 10:05 到 10:20，或者 2026-07-13 14点到14点半"
        self.log_ai_placeholder_active = False

        ttk.Label(top, text="项目").grid(row=0, column=0, padx=(0, 8))
        self.log_project_combo = ttk.Combobox(
            top,
            textvariable=self.log_project,
            state="readonly",
            width=42,
        )
        self.log_project_combo.grid(row=0, column=1, sticky="ew", padx=(0, 12))
        self.log_project_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_log_services())
        ttk.Label(top, textvariable=self.log_project_mdm).grid(
            row=0, column=2, sticky="w", padx=(0, 12)
        )
        ttk.Button(top, text="刷新项目", command=self.refresh_projects).grid(row=0, column=3)

        manual_frame = ttk.LabelFrame(self.log_tab, text="手动填写日志时间", padding=12, style="Section.TLabelframe")
        manual_frame.grid(row=1, column=0, sticky="ew", padx=12, pady=6)
        manual_frame.columnconfigure(1, weight=1)
        manual_frame.columnconfigure(3, weight=1)
        manual_frame.columnconfigure(5, weight=1)
        manual_frame.columnconfigure(7, weight=1)

        ttk.Label(manual_frame, text="服务").grid(row=0, column=0, padx=(0, 8))
        self.log_service_combo = ttk.Combobox(
            manual_frame,
            textvariable=self.log_service,
            state="readonly",
        )
        self.log_service_combo.grid(row=0, column=1, sticky="ew", padx=(0, 12))
        ttk.Label(manual_frame, text="日期").grid(row=0, column=2, padx=(0, 8))
        ttk.Entry(manual_frame, textvariable=self.log_date).grid(row=0, column=3, sticky="ew", padx=(0, 12))
        ttk.Label(manual_frame, text="开始").grid(row=0, column=4, padx=(0, 8))
        ttk.Entry(manual_frame, textvariable=self.log_start_time).grid(row=0, column=5, sticky="ew", padx=(0, 12))
        ttk.Label(manual_frame, text="结束").grid(row=0, column=6, padx=(0, 8))
        ttk.Entry(manual_frame, textvariable=self.log_end_time).grid(row=0, column=7, sticky="ew")

        ai_frame = ttk.LabelFrame(self.log_tab, text="AI解析时间描述", padding=12, style="Section.TLabelframe")
        ai_frame.grid(row=2, column=0, sticky="nsew", padx=12, pady=6)
        ai_frame.columnconfigure(0, weight=1)
        ai_frame.rowconfigure(0, weight=1)

        self.log_ai_input = tk.Text(ai_frame, height=5, wrap="word")
        self.log_ai_input.grid(row=0, column=0, sticky="nsew")
        self._restore_log_ai_placeholder()
        self.log_ai_input.bind("<FocusIn>", lambda _event: self._clear_log_ai_placeholder())
        self.log_ai_input.bind("<FocusOut>", lambda _event: self._restore_log_ai_placeholder())
        ttk.Button(ai_frame, text="AI解析时间", command=self.parse_log_time_with_ai).grid(
            row=1, column=0, sticky="e", pady=(8, 0)
        )

        actions = ttk.Frame(self.log_tab, padding=(12, 4, 12, 4))
        actions.grid(row=3, column=0, sticky="ew")
        ttk.Button(actions, text="生成截取命令", command=self.generate_log_extract_command).grid(
            row=0, column=0, padx=(0, 8)
        )
        ttk.Button(actions, text="复制命令", command=self.copy_log_command).grid(row=0, column=1, padx=(0, 8))
        ttk.Button(actions, text="清空命令", command=lambda: self._set_log_output("")).grid(row=0, column=2)

        output_frame = ttk.LabelFrame(self.log_tab, text="日志截取命令", padding=12, style="Section.TLabelframe")
        output_frame.grid(row=4, column=0, sticky="nsew", padx=12, pady=(6, 12))
        output_frame.columnconfigure(0, weight=1)
        output_frame.rowconfigure(0, weight=1)

        self.log_command_output = tk.Text(output_frame, height=8, wrap="none")
        log_output_scrollbar = ttk.Scrollbar(output_frame, orient="vertical", command=self.log_command_output.yview)
        self.log_command_output.configure(yscrollcommand=log_output_scrollbar.set)
        self.log_command_output.grid(row=0, column=0, sticky="nsew")
        log_output_scrollbar.grid(row=0, column=1, sticky="ns")

    def refresh_projects(self) -> None:
        try:
            self.projects = self.store.list_projects()
        except StorageError as exc:
            messagebox.showerror("读取失败", str(exc), parent=self)
            self.projects = []
        self._render_projects()
        self._refresh_backup_projects()
        self._refresh_migration_projects()
        self._refresh_ai_projects()
        self._refresh_log_projects()

    def add_project(self) -> None:
        def save(project_name, vpn_type, mdm_version, vpn_user, vpn_password) -> None:
            self.store.create_project(
                project_name, vpn_type, mdm_version, vpn_user, vpn_password
            )
            self.refresh_projects()

        ProjectDialog(self, "新增项目", save)

    def edit_project(self) -> None:
        project = self._selected_project()
        if project is None:
            messagebox.showinfo("请选择项目", "请先选择一个需要编辑的项目。", parent=self)
            return

        def save(project_name, vpn_type, mdm_version, vpn_user, vpn_password) -> None:
            self.store.update_project(
                project["id"], project_name, vpn_type, mdm_version, vpn_user, vpn_password
            )
            self.refresh_projects()

        ProjectDialog(self, "编辑项目", save, project)

    def delete_project(self) -> None:
        project = self._selected_project()
        if project is None:
            messagebox.showinfo("请选择项目", "请先选择一个需要删除的项目。", parent=self)
            return

        confirmed = messagebox.askyesno(
            "确认删除",
            f"确定删除项目「{project['project_name']}」吗？对应 VPN 账号密码也会删除。",
            parent=self,
        )
        if not confirmed:
            return

        try:
            self.store.delete_project(project["id"])
        except StorageError as exc:
            messagebox.showerror("删除失败", str(exc), parent=self)
            return
        self.refresh_projects()

    def _render_projects(self) -> None:
        for item in self.tree.get_children():
            self.tree.delete(item)

        keyword = self.search_text.get().strip().casefold()
        visible = 0
        for project in self.projects:
            searchable = " ".join(
                [
                    project["project_name"],
                    project["vpn_type"],
                    project["vpn_user"],
                    project["mdm_version"],
                ]
            ).casefold()
            if keyword and keyword not in searchable:
                continue
            visible += 1
            self.tree.insert(
                "",
                "end",
                iid=project["id"],
                values=(
                    project["project_name"],
                    project["vpn_type"],
                    project["vpn_user"],
                    self._masked_password(project["vpn_password"]),
                    project["mdm_version"],
                ),
            )
        self.status.configure(text=f"共 {len(self.projects)} 个项目，当前显示 {visible} 个。")

    def _refresh_backup_projects(self) -> None:
        current_project_id = self.backup_project_options.get(self.backup_project.get(), "")
        self.backup_project_options = {
            self._backup_project_label(project): project["id"] for project in self.projects
        }
        labels = list(self.backup_project_options.keys())
        self.backup_project_combo.configure(values=labels)

        selected_label = ""
        for label, project_id in self.backup_project_options.items():
            if project_id == current_project_id:
                selected_label = label
                break
        if not selected_label and labels:
            selected_label = labels[0]

        self.backup_project.set(selected_label)
        self._load_backup_services()

    def _load_backup_services(self) -> None:
        self._clear_tree(self.service_tree)
        self._set_command_output("")
        self.backup_services = []

        project = self._selected_backup_project()
        if project is None:
            self.backup_mdm_version.set("MDM版本号：")
            return

        mdm_version = project["mdm_version"]
        self.backup_mdm_version.set(f"MDM版本号：{mdm_version}")
        self.backup_services = service_configs_for_mdm_version(mdm_version)
        for index, service in enumerate(self.backup_services):
            self.service_tree.insert(
                "",
                "end",
                iid=str(index),
                values=(
                    service["service_name"],
                    service["jar_name"],
                    service["deploy_dir"],
                ),
            )

        if not self.backup_services:
            self._set_command_output(service_config_message(mdm_version))

    def select_all_services(self) -> None:
        self.service_tree.selection_set(self.service_tree.get_children())

    def clear_service_selection(self) -> None:
        self.service_tree.selection_remove(self.service_tree.selection())

    def edit_backup_service(self) -> None:
        selected = self.service_tree.selection()
        if len(selected) != 1:
            messagebox.showinfo("请选择服务", "请先选择一个需要编辑的服务。", parent=self)
            return

        index = int(selected[0])

        def save(updated_service: dict[str, str]) -> None:
            self.backup_services[index] = updated_service
            self._update_service_tree_item(self.service_tree, index, updated_service)

        ServiceEditDialog(self, "编辑备份服务", self.backup_services[index], save)

    def generate_backup_commands(self) -> None:
        project = self._selected_backup_project()
        if project is None:
            messagebox.showinfo("请选择项目", "请先选择一个项目。", parent=self)
            return

        selected_indexes = self.service_tree.selection()
        if not selected_indexes:
            messagebox.showinfo("请选择服务", "请先选择一个或多个 Java 服务包。", parent=self)
            return

        date_suffix = datetime.now().strftime("%Y%m%d")
        commands = []
        for item_id in selected_indexes:
            service = self.backup_services[int(item_id)]
            source_path = join_remote_path(service["deploy_dir"], service["jar_name"])
            commands.append(
                f"mv {shell_quote(source_path)} {shell_quote(source_path + date_suffix)}"
            )
        self._set_command_output("\n".join(commands))

    def copy_backup_commands(self) -> None:
        commands = self.command_output.get("1.0", tk.END).strip()
        if not commands:
            messagebox.showinfo("没有命令", "请先生成备份命令。", parent=self)
            return
        self.clipboard_clear()
        self.clipboard_append(commands)
        messagebox.showinfo("已复制", "备份命令已复制到剪贴板。", parent=self)

    def _refresh_migration_projects(self) -> None:
        current_project_id = self.migration_project_options.get(
            self.migration_project.get(), ""
        )
        self.migration_project_options = {
            self._backup_project_label(project): project["id"] for project in self.projects
        }
        labels = list(self.migration_project_options.keys())
        self.migration_project_combo.configure(values=labels)

        selected_label = ""
        for label, project_id in self.migration_project_options.items():
            if project_id == current_project_id:
                selected_label = label
                break
        if not selected_label and labels:
            selected_label = labels[0]

        self.migration_project.set(selected_label)
        self._load_migration_services()

    def _load_migration_services(self) -> None:
        self._clear_tree(self.migration_service_tree)
        self._set_migration_output("")
        self.migration_services = []

        project = self._selected_migration_project()
        if project is None:
            self.migration_mdm_version.set("MDM版本号：")
            return

        mdm_version = project["mdm_version"]
        self.migration_mdm_version.set(f"MDM版本号：{mdm_version}")
        self.migration_services = service_configs_for_mdm_version(mdm_version)
        for index, service in enumerate(self.migration_services):
            self.migration_service_tree.insert(
                "",
                "end",
                iid=str(index),
                values=(
                    service["service_name"],
                    service["jar_name"],
                    service["deploy_dir"],
                ),
            )

        if not self.migration_services:
            self._set_migration_output(service_config_message(mdm_version))

    def select_all_migration_services(self) -> None:
        self.migration_service_tree.selection_set(self.migration_service_tree.get_children())

    def clear_migration_service_selection(self) -> None:
        self.migration_service_tree.selection_remove(self.migration_service_tree.selection())

    def edit_migration_service(self) -> None:
        selected = self.migration_service_tree.selection()
        if len(selected) != 1:
            messagebox.showinfo("请选择服务", "请先选择一个需要编辑的服务。", parent=self)
            return

        index = int(selected[0])

        def save(updated_service: dict[str, str]) -> None:
            self.migration_services[index] = updated_service
            self._update_service_tree_item(
                self.migration_service_tree, index, updated_service
            )

        ServiceEditDialog(self, "编辑迁移服务", self.migration_services[index], save)

    def generate_migration_commands(self) -> None:
        project = self._selected_migration_project()
        if project is None:
            messagebox.showinfo("请选择项目", "请先选择一个项目。", parent=self)
            return

        source_dir = self.source_dir.get().strip()
        if not source_dir:
            messagebox.showinfo("请填写目录", "请填写 A 目录。", parent=self)
            return

        selected_indexes = self.migration_service_tree.selection()
        if not selected_indexes:
            messagebox.showinfo("请选择服务", "请先选择一个或多个 Java 服务包。", parent=self)
            return

        confirmed = messagebox.askyesno(
            "迁移前确认",
            "文件迁移可能覆盖目标目录中已有的 jar 包。\n\n请确认已经优先完成备份，或已经生成并核对备份命令。\n\n是否继续生成迁移命令？",
            parent=self,
        )
        if not confirmed:
            return

        commands = []
        for item_id in selected_indexes:
            service = self.migration_services[int(item_id)]
            source_path = join_remote_path(source_dir, service["jar_name"])
            target_dir = ensure_remote_directory(service["deploy_dir"])
            commands.append(f"mv {shell_quote(source_path)} {shell_quote(target_dir)}")
        self._set_migration_output("\n".join(commands))

    def copy_migration_commands(self) -> None:
        commands = self.migration_command_output.get("1.0", tk.END).strip()
        if not commands:
            messagebox.showinfo("没有命令", "请先生成迁移命令。", parent=self)
            return
        self.clipboard_clear()
        self.clipboard_append(commands)
        messagebox.showinfo("已复制", "迁移命令已复制到剪贴板。", parent=self)

    def _refresh_ai_projects(self) -> None:
        current_project_id = self.ai_project_options.get(self.ai_project.get(), "")
        self.ai_project_options = {
            self._backup_project_label(project): project["id"] for project in self.projects
        }
        labels = list(self.ai_project_options.keys())
        self.ai_project_combo.configure(values=labels)

        selected_label = ""
        for label, project_id in self.ai_project_options.items():
            if project_id == current_project_id:
                selected_label = label
                break
        if not selected_label and labels:
            selected_label = labels[0]
        self.ai_project.set(selected_label)

    def _refresh_log_projects(self) -> None:
        current_project_id = self.log_project_options.get(self.log_project.get(), "")
        self.log_project_options = {
            self._backup_project_label(project): project["id"] for project in self.projects
        }
        labels = list(self.log_project_options.keys())
        self.log_project_combo.configure(values=labels)

        selected_label = ""
        for label, project_id in self.log_project_options.items():
            if project_id == current_project_id:
                selected_label = label
                break
        if not selected_label and labels:
            selected_label = labels[0]
        self.log_project.set(selected_label)
        self._load_log_services()

    def _load_log_services(self) -> None:
        project = self._selected_log_project()
        self.log_services = []
        self.log_service.set("")
        self.log_service_combo.configure(values=[])
        if project is None:
            self.log_project_mdm.set("MDM版本号：")
            return

        mdm_version = project["mdm_version"]
        self.log_project_mdm.set(f"MDM版本号：{mdm_version}")
        self.log_services = service_configs_for_mdm_version(mdm_version)
        labels = [self._log_service_label(service) for service in self.log_services]
        self.log_service_combo.configure(values=labels)
        if labels:
            self.log_service.set(labels[0])

    def save_ai_config(self) -> None:
        config = {
            "base_url": self.ai_base_url.get(),
            "model": self.ai_model.get(),
            "api_key": self.ai_api_key.get(),
        }
        try:
            self.ai_config_store.save(config)
            self.ai_config = self.ai_config_store.load()
        except AIError as exc:
            messagebox.showerror("保存失败", str(exc), parent=self)
            return
        messagebox.showinfo("已保存", "AI配置已保存到 ai_config.json。", parent=self)

    def run_ai_service_analysis(self) -> None:
        project = self._selected_ai_project()
        if project is None:
            messagebox.showinfo("请选择项目", "请先选择一个项目。", parent=self)
            return

        raw_listing = self.ai_input.get("1.0", tk.END).strip()
        config = {
            "base_url": self.ai_base_url.get().strip() or DEFAULT_AI_BASE_URL,
            "model": self.ai_model.get().strip() or DEFAULT_AI_MODEL,
            "api_key": self.ai_api_key.get().strip(),
        }
        client = DeepSeekClient(
            base_url=config["base_url"],
            model=config["model"],
            api_key=config["api_key"],
        )

        self.ai_summary.configure(text="AI识别中，请稍候...")
        self.update_idletasks()
        try:
            result = client.analyze_services(project, raw_listing)
        except AIError as exc:
            self.ai_summary.configure(text="AI识别失败。")
            messagebox.showerror("AI识别失败", str(exc), parent=self)
            return

        self.ai_result = result
        self._render_ai_result(result)

    def parse_log_time_with_ai(self) -> None:
        description = self._get_log_ai_description()
        config = {
            "base_url": self.ai_base_url.get().strip() or DEFAULT_AI_BASE_URL,
            "model": self.ai_model.get().strip() or DEFAULT_AI_MODEL,
            "api_key": self.ai_api_key.get().strip(),
        }
        client = DeepSeekClient(
            base_url=config["base_url"],
            model=config["model"],
            api_key=config["api_key"],
        )
        try:
            result = client.parse_log_time(
                description, current_date=datetime.now().strftime("%Y-%m-%d")
            )
            query = LogQuery(
                log_date=result["log_date"],
                start_time=result["start_time"],
                end_time=result["end_time"],
            )
            build_log_extract_command("/tmp", query)
        except (AIError, ValueError) as exc:
            messagebox.showerror("AI解析失败", str(exc), parent=self)
            return

        self.log_date.set(result["log_date"])
        self.log_start_time.set(result["start_time"])
        self.log_end_time.set(result["end_time"])
        explanation = result.get("explanation", "")
        if explanation:
            messagebox.showinfo("AI解析完成", explanation, parent=self)

    def _get_log_ai_description(self) -> str:
        if self.log_ai_placeholder_active:
            return ""
        return self.log_ai_input.get("1.0", tk.END).strip()

    def _clear_log_ai_placeholder(self) -> None:
        if not self.log_ai_placeholder_active:
            return
        self.log_ai_input.delete("1.0", tk.END)
        self.log_ai_input.configure(fg="#111827")
        self.log_ai_placeholder_active = False

    def _restore_log_ai_placeholder(self) -> None:
        if self.log_ai_input.get("1.0", tk.END).strip():
            return
        self.log_ai_input.delete("1.0", tk.END)
        self.log_ai_input.insert("1.0", self.log_ai_placeholder)
        self.log_ai_input.configure(fg="#94a3b8")
        self.log_ai_placeholder_active = True

    def generate_log_extract_command(self) -> None:
        service = self._selected_log_service()
        if service is None:
            messagebox.showinfo("请选择服务", "请先选择一个服务。", parent=self)
            return

        query = LogQuery(
            log_date=self.log_date.get().strip(),
            start_time=self.log_start_time.get().strip(),
            end_time=self.log_end_time.get().strip(),
        )
        try:
            command = build_log_extract_command(service["deploy_dir"], query)
        except ValueError as exc:
            messagebox.showerror("生成失败", str(exc), parent=self)
            return
        self._set_log_output(command)

    def copy_log_command(self) -> None:
        command = self.log_command_output.get("1.0", tk.END).strip()
        if not command:
            messagebox.showinfo("没有命令", "请先生成日志截取命令。", parent=self)
            return
        self.clipboard_clear()
        self.clipboard_append(command)
        messagebox.showinfo("已复制", "日志截取命令已复制到剪贴板。", parent=self)

    def copy_ai_result(self) -> None:
        if self.ai_result is None:
            messagebox.showinfo("没有结果", "请先执行 AI 服务识别。", parent=self)
            return
        text = json.dumps(self.ai_result, ensure_ascii=False, indent=2)
        self.clipboard_clear()
        self.clipboard_append(text)
        messagebox.showinfo("已复制", "AI识别 JSON 结果已复制到剪贴板。", parent=self)

    def apply_ai_services_to_command_pages(self) -> None:
        if self.ai_result is None:
            messagebox.showinfo("没有结果", "请先执行 AI 服务识别。", parent=self)
            return

        services = self.ai_result.get("services", [])
        if not isinstance(services, list) or not services:
            messagebox.showinfo("没有服务", "AI识别结果中没有可应用的服务目录。", parent=self)
            return

        confirmed = messagebox.askyesno(
            "应用识别目录",
            "是否把 AI 识别出的 jar 文件名和真实部署目录应用到当前项目的「备份命令」和「文件迁移」服务表格？\n\n"
            "该操作只影响当前界面生成命令，不会写入项目 JSON。",
            parent=self,
        )
        if not confirmed:
            return

        backup_count = self._apply_services_to_current_configs(self.backup_services, services)
        migration_count = self._apply_services_to_current_configs(
            self.migration_services, services
        )
        log_count = self._apply_services_to_current_configs(self.log_services, services)
        self._render_service_configs(self.service_tree, self.backup_services)
        self._render_service_configs(self.migration_service_tree, self.migration_services)
        self._render_log_service_options()
        messagebox.showinfo(
            "应用完成",
            f"已更新备份服务 {backup_count} 项，文件迁移服务 {migration_count} 项，日志服务 {log_count} 项。",
            parent=self,
        )

    def clear_ai_page(self) -> None:
        self.ai_input.delete("1.0", tk.END)
        self.ai_result = None
        self.ai_summary.configure(text="尚未识别。")
        self._clear_tree(self.ai_service_tree)
        self.ai_risk_output.delete("1.0", tk.END)

    def _render_ai_result(self, result: dict[str, object]) -> None:
        self._clear_tree(self.ai_service_tree)
        services = result.get("services", [])
        if isinstance(services, list):
            for index, service in enumerate(services):
                if not isinstance(service, dict):
                    continue
                self.ai_service_tree.insert(
                    "",
                    "end",
                    iid=str(index),
                    values=(
                        service.get("service_name", ""),
                        service.get("jar_name", ""),
                        service.get("jar_version", ""),
                        service.get("deploy_dir", ""),
                        "是" if service.get("is_custom") else "否",
                        service.get("risk", ""),
                    ),
                )

        detected_version = result.get("detected_mdm_version", "")
        jar_version = result.get("detected_jar_version", "")
        summary = result.get("summary", "")
        self.ai_summary.configure(
            text=f"{summary}  识别版本：{detected_version}  jar版本：{jar_version}"
        )

        risk_lines = []
        for title, key in [
            ("缺失标准服务", "missing_standard_services"),
            ("额外/定制服务", "extra_custom_services"),
            ("版本冲突", "version_conflicts"),
            ("整体风险", "risks"),
        ]:
            values = result.get(key, [])
            if isinstance(values, list) and values:
                risk_lines.append(f"{title}:")
                risk_lines.extend(f"- {value}" for value in values)
        self.ai_risk_output.delete("1.0", tk.END)
        self.ai_risk_output.insert(
            "1.0",
            "\n".join(risk_lines) if risk_lines else "未发现明显缺失、定制或风险提示。",
        )

    def _apply_services_to_current_configs(
        self, target_services: list[dict[str, str]], ai_services: list[object]
    ) -> int:
        updated = 0
        for ai_service in ai_services:
            if not isinstance(ai_service, dict):
                continue
            ai_jar = str(ai_service.get("jar_name", "")).strip()
            ai_name = str(ai_service.get("service_name", "")).strip()
            ai_dir = ensure_remote_directory(str(ai_service.get("deploy_dir", "")).strip())
            if not ai_jar or not ai_dir:
                continue

            target = self._find_matching_service(target_services, ai_name, ai_jar)
            if target is None:
                target_services.append(
                    {
                        "service_name": ai_name or ai_jar.removesuffix(".jar"),
                        "jar_name": ai_jar,
                        "deploy_dir": ai_dir,
                    }
                )
                updated += 1
                continue

            target["jar_name"] = ai_jar
            target["deploy_dir"] = ai_dir
            if ai_name:
                target["service_name"] = ai_name
            updated += 1
        return updated

    def _find_matching_service(
        self, services: list[dict[str, str]], service_name: str, jar_name: str
    ) -> dict[str, str] | None:
        jar_key = service_key_from_jar(jar_name)
        for service in services:
            if service_name and service.get("service_name") == service_name:
                return service
            if jar_key and service_key_from_jar(service.get("jar_name", "")) == jar_key:
                return service
        return None

    def _render_service_configs(
        self, tree: ttk.Treeview, services: list[dict[str, str]]
    ) -> None:
        self._clear_tree(tree)
        for index, service in enumerate(services):
            tree.insert(
                "",
                "end",
                iid=str(index),
                values=(
                    service["service_name"],
                    service["jar_name"],
                    service["deploy_dir"],
                ),
            )

    def _render_log_service_options(self) -> None:
        current = self.log_service.get()
        labels = [self._log_service_label(service) for service in self.log_services]
        self.log_service_combo.configure(values=labels)
        self.log_service.set(current if current in labels else (labels[0] if labels else ""))

    def _set_command_output(self, value: str) -> None:
        self.command_output.delete("1.0", tk.END)
        if value:
            self.command_output.insert("1.0", value)

    def _set_migration_output(self, value: str) -> None:
        self.migration_command_output.delete("1.0", tk.END)
        if value:
            self.migration_command_output.insert("1.0", value)

    def _set_log_output(self, value: str) -> None:
        self.log_command_output.delete("1.0", tk.END)
        if value:
            self.log_command_output.insert("1.0", value)

    def _clear_tree(self, tree: ttk.Treeview) -> None:
        for item in tree.get_children():
            tree.delete(item)

    def _update_service_tree_item(
        self, tree: ttk.Treeview, index: int, service: dict[str, str]
    ) -> None:
        tree.item(
            str(index),
            values=(
                service["service_name"],
                service["jar_name"],
                service["deploy_dir"],
            ),
        )

    def _selected_backup_project(self) -> dict[str, str] | None:
        project_id = self.backup_project_options.get(self.backup_project.get())
        if not project_id:
            return None
        for project in self.projects:
            if project["id"] == project_id:
                return project
        return None

    def _selected_migration_project(self) -> dict[str, str] | None:
        project_id = self.migration_project_options.get(self.migration_project.get())
        if not project_id:
            return None
        for project in self.projects:
            if project["id"] == project_id:
                return project
        return None

    def _selected_ai_project(self) -> dict[str, str] | None:
        project_id = self.ai_project_options.get(self.ai_project.get())
        if not project_id:
            return None
        for project in self.projects:
            if project["id"] == project_id:
                return project
        return None

    def _selected_log_project(self) -> dict[str, str] | None:
        project_id = self.log_project_options.get(self.log_project.get())
        if not project_id:
            return None
        for project in self.projects:
            if project["id"] == project_id:
                return project
        return None

    def _selected_log_service(self) -> dict[str, str] | None:
        selected = self.log_service.get()
        for service in self.log_services:
            if self._log_service_label(service) == selected:
                return service
        return None

    def _backup_project_label(self, project: dict[str, str]) -> str:
        return f"{project['project_name']}（{project['mdm_version']}）"

    def _log_service_label(self, service: dict[str, str]) -> str:
        return f"{service['service_name']}  {service['deploy_dir']}"

    def _selected_project(self) -> dict[str, str] | None:
        selected = self.tree.selection()
        if not selected:
            return None
        project_id = selected[0]
        for project in self.projects:
            if project["id"] == project_id:
                return project
        return None

    def _masked_password(self, password: str) -> str:
        return "*" * min(len(password), 8) if password else ""


if __name__ == "__main__":
    app = ProjectApp()
    app.mainloop()
